1
0

refactor(actions): deduplicate audit metadata and grid user count enrichment

This commit is contained in:
2026-03-13 12:05:11 +01:00
parent 892da0048d
commit 010690de19
17 changed files with 638 additions and 143 deletions

View File

@@ -0,0 +1,125 @@
{
"task_id": "CODE-DEDUP-ACTIONS-001",
"plan_ref": "agent-system/runs/CODE-DEDUP-ACTIONS-001/plan.json",
"status": "done",
"changed_files": [
{
"path": "lib/Service/Audit/AuditMetadataEnricher.php",
"summary": "CREATED — S1: New service resolving audit user IDs (created_by, modified_by, etc.) into display labels and UUIDs. Accepts configurable field list. Depends on UserReadRepositoryInterface."
},
{
"path": "lib/Service/Data/GridUserCountEnricher.php",
"summary": "CREATED — S2: New stateless service that collects entity IDs from grid rows, batch-queries total and active user counts via callables, and returns a lookup map."
},
{
"path": "tests/Service/Audit/AuditMetadataEnricherTest.php",
"summary": "CREATED — S3: 6 tests covering label resolution, email fallback, zero/missing IDs, user not found, and custom fields."
},
{
"path": "tests/Service/Data/GridUserCountEnricherTest.php",
"summary": "CREATED — S3: 6 tests covering typical rows, empty rows, ID deduplication, zero IDs, zero counts, and negative inactive clamping."
},
{
"path": "lib/App/Container/Registrars/AppServicesRegistrar.php",
"summary": "S4: Registered AuditMetadataEnricher (with UserReadRepository dep) and GridUserCountEnricher (stateless) in DI container."
},
{
"path": "pages/admin/tenants/edit($id).php",
"summary": "S5: Replaced 27 lines of inline audit metadata resolution (created_by, modified_by, status_changed_by) with single AuditMetadataEnricher call. Removed unused $userAccountService variable."
},
{
"path": "pages/admin/roles/edit($id).php",
"summary": "S5: Replaced 18 lines of inline audit metadata resolution (created_by, modified_by) with single AuditMetadataEnricher call. Removed unused $userAccountService variable."
},
{
"path": "pages/admin/departments/edit($id).php",
"summary": "S5: Replaced 18 lines of inline audit metadata resolution (created_by, modified_by) with single AuditMetadataEnricher call. Removed unused $userAccountService variable."
},
{
"path": "pages/admin/users/edit($id).php",
"summary": "S5: Replaced 28 lines of inline audit metadata resolution (created_by, modified_by, active_changed_by) with single AuditMetadataEnricher call inside permission guard."
},
{
"path": "pages/admin/roles/data().php",
"summary": "S6: Replaced 12 lines of ID collection + user count aggregation with GridUserCountEnricher.computeCounts() call. Row loop uses count map lookup."
},
{
"path": "pages/admin/departments/data().php",
"summary": "S6: Replaced 11 lines of ID collection + user count aggregation with GridUserCountEnricher.computeCounts() call. Row loop uses count map lookup."
},
{
"path": "pages/admin/tenants/data().php",
"summary": "S6: Replaced 8 lines of ID collection + user count aggregation inside $fetchRows closure with GridUserCountEnricher.computeCounts() call. Also uses enricher IDs for SSO map query."
}
],
"guard_evidence": [
{
"guard_id": "GR-CORE-001",
"status": "pass",
"evidence": "AuditMetadataEnricher is a Service class with single repository dependency (UserReadRepositoryInterface). GridUserCountEnricher is a stateless Service with no dependencies. Both follow the Service contract — business logic, no HTML, no orchestration flow."
},
{
"guard_id": "GR-CORE-002",
"status": "pass",
"evidence": "Strict layering preserved. New services registered in AppServicesRegistrar. Action files call services via app() helper. No cross-layer violations. No circular dependencies."
},
{
"guard_id": "GR-TEST-001",
"status": "pass",
"evidence": "12 new tests added (6 per service). 603 total tests run, 0 errors introduced by this task. 1 pre-existing failure (TranslationKeysTest) unrelated."
},
{
"guard_id": "GR-TEST-002",
"status": "pass",
"evidence": "New tests cover edge cases: zero IDs, missing fields, user not found, email fallback, ID deduplication, negative inactive clamping. No existing tests modified."
},
{
"guard_id": "GR-LANG-001",
"status": "pass",
"evidence": "All new PHP files follow PSR-4 namespace conventions. Import ordering auto-fixed by php-cs-fixer."
},
{
"guard_id": "GR-LANG-002",
"status": "pass",
"evidence": "PHPStan level 5: 0 new errors from this task. 4 pre-existing errors in AuthzUiContractTest.php are unrelated."
}
],
"commands": [
{ "cmd": "docker compose exec php vendor/bin/phpunit --no-coverage", "result": "pass" },
{ "cmd": "docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress", "result": "pass" },
{ "cmd": "docker compose exec php vendor/bin/php-cs-fixer fix --dry-run --config=.php-cs-fixer.dist.php", "result": "pass" },
{ "cmd": "docker compose exec php vendor/bin/phpunit --filter CoreStarterkitContractTest|ContainerFactoryContractTest", "result": "pass" },
{ "cmd": "rg 'AuditMetadataEnricher' pages/admin/ (SC-001: usage in 4 edit action files)", "result": "pass" },
{ "cmd": "rg 'GridUserCountEnricher' pages/admin/ (SC-002: usage in 3 data endpoint files)", "result": "pass" },
{ "cmd": "rg 'created_by_label.*=.*trim' pages/ (SC-003: zero inline audit resolution left)", "result": "pass" }
],
"quality_gate_results": [
{
"gate_id": "QG-001",
"result": "pass",
"notes": "603 tests, 0 errors introduced by this task. 12 new tests added. 1 pre-existing failure (TranslationKeysTest: missing i18n key) — verified pre-existing: same failure reproduces on clean branch (git stash → phpunit → same failure). This task introduced zero test failures."
},
{
"gate_id": "QG-002",
"result": "pass",
"notes": "0 new PHPStan errors introduced by this task. 4 pre-existing errors in tests/Architecture/AuthzUiContractTest.php (nullable offset warnings) — verified pre-existing: present on clean branch before any task changes. This task introduced zero PHPStan errors."
},
{
"gate_id": "QG-003",
"result": "pass",
"notes": "Architecture contract tests (CoreStarterkitContractTest + ContainerFactoryContractTest): 24 tests, 6454 assertions, all green."
},
{
"gate_id": "QG-006",
"result": "pass",
"notes": "php-cs-fixer fix --dry-run: 0 of 526 files need fixing."
}
],
"test_results": [
{ "name": "tests/Service/Audit/AuditMetadataEnricherTest.php", "result": "pass" },
{ "name": "tests/Service/Data/GridUserCountEnricherTest.php", "result": "pass" },
{ "name": "tests/Architecture/CoreStarterkitContractTest.php", "result": "pass" },
{ "name": "tests/Architecture/ContainerFactoryContractTest.php", "result": "pass" }
],
"open_items": []
}

View File

@@ -0,0 +1,10 @@
{
"task_id": "CODE-DEDUP-ACTIONS-001",
"ready_to_finalize": true,
"guard_review": "pass",
"acceptance_review": "pass",
"ci_status": "pass",
"final_action": "commit",
"commit_message": "refactor(actions): deduplicate audit metadata and grid user count enrichment",
"notes": "review-guards and review-acceptance are pass; execution-report documents QG-001/QG-002/QG-003/QG-006 as pass with no task-introduced failures."
}

View File

@@ -0,0 +1,128 @@
{
"task_id": "CODE-DEDUP-ACTIONS-001",
"summary": "Extract repeated action-layer patterns (audit metadata enrichment, grid user count calculation) into shared service classes to eliminate ~260 lines of duplication across 7+ action/data files.",
"assumptions": [
"Audit metadata enrichment (created_by, modified_by labels) repeats identically across 4+ edit actions.",
"Grid user count calculation repeats identically across 3 data endpoints (roles, departments, tenants).",
"Both patterns are pure data transformation — suitable for service extraction without changing action flow."
],
"scope": {
"in": [
"New: lib/Service/Audit/AuditMetadataEnricher.php — extract audit label resolution",
"New: lib/Service/Data/GridUserCountEnricher.php — extract user count aggregation for grid data endpoints",
"pages/admin/tenants/edit($id).php — replace inline audit metadata with service call",
"pages/admin/roles/edit($id).php — replace inline audit metadata with service call",
"pages/admin/users/edit($tenantslug,$id).php — replace inline audit metadata with service call",
"pages/admin/departments/edit($id).php — replace inline audit metadata with service call",
"pages/admin/roles/data().php — replace inline user count logic with service call",
"pages/admin/departments/data().php — replace inline user count logic with service call",
"pages/admin/tenants/data().php — replace inline user count logic with service call",
"Factory registrars for new services"
],
"out": [
"View files (.phtml) — not touched",
"Repository SQL queries — not touched",
"Frontend JS/CSS — not touched",
"Permission/authorization logic — not touched"
]
},
"guardrails": {
"required_guard_ids": [
"GR-CORE-001",
"GR-CORE-002",
"GR-TEST-001",
"GR-TEST-002",
"GR-LANG-001",
"GR-LANG-002"
],
"required_quality_gate_ids": [
"QG-001",
"QG-002",
"QG-003",
"QG-006"
]
},
"success_criteria": [
{
"id": "SC-001",
"criterion": "AuditMetadataEnricher exists in lib/Service/Audit/ and is used by all 4 edit actions instead of inline created_by/modified_by resolution."
},
{
"id": "SC-002",
"criterion": "GridUserCountEnricher exists in lib/Service/Data/ and is used by all 3 data endpoints instead of inline count aggregation."
},
{
"id": "SC-003",
"criterion": "No inline audit-label or user-count-aggregation logic remains in pages/ action files."
},
{
"id": "SC-004",
"criterion": "PHPUnit, PHPStan, Architecture Contract, and CS checks pass green; zero new failures introduced by this task (pre-existing failures are out of scope)."
}
],
"implementation_steps": [
{
"id": "S1",
"title": "Create AuditMetadataEnricher service",
"description": "Extract the repeated pattern of resolving created_by, modified_by, status_changed_by, active_changed_by into display labels (name or email fallback) and UUIDs. Accept entity array by reference, list of audit fields to resolve, and UserAccountService dependency.",
"guard_refs": ["GR-CORE-001", "GR-TEST-002"]
},
{
"id": "S2",
"title": "Create GridUserCountEnricher service",
"description": "Extract repeated pattern of collecting entity IDs from grid rows, querying user counts (total + active), and enriching rows with count fields. Generic enough to work with roles, departments, and tenants.",
"guard_refs": ["GR-CORE-001", "GR-TEST-002"]
},
{
"id": "S3",
"title": "Write unit tests for both new services",
"description": "AuditMetadataEnricher: test with present/missing/zero audit IDs, user not found fallback. GridUserCountEnricher: test with empty rows, mixed IDs, zero counts.",
"guard_refs": ["GR-TEST-001"]
},
{
"id": "S4",
"title": "Register new services in DI container",
"description": "Add factory registrations in appropriate Container/Registrars/ files.",
"guard_refs": ["GR-CORE-002"]
},
{
"id": "S5",
"title": "Refactor edit actions to use AuditMetadataEnricher",
"description": "Replace inline audit metadata resolution in all 4 edit actions with enricher service call.",
"guard_refs": ["GR-CORE-001", "GR-CORE-002"]
},
{
"id": "S6",
"title": "Refactor data endpoints to use GridUserCountEnricher",
"description": "Replace inline user count aggregation in all 3 data endpoints with enricher service call.",
"guard_refs": ["GR-CORE-001", "GR-CORE-002"]
},
{
"id": "S7",
"title": "Run all quality gates",
"description": "QG-001, QG-002, QG-003, QG-006.",
"guard_refs": ["GR-LANG-002"]
}
],
"tests": [
"tests/Service/Audit/AuditMetadataEnricherTest.php — new, covers label resolution edge cases",
"tests/Service/Data/GridUserCountEnricherTest.php — new, covers count aggregation edge cases",
"tests/Architecture/CoreStarterkitContractTest.php — must stay green"
],
"acceptance_checks": [
"SC-001: rg 'AuditMetadataEnricher' pages/admin/ shows usage in 4 edit action files",
"SC-002: rg 'GridUserCountEnricher' pages/admin/ shows usage in 3 data endpoint files",
"SC-003: rg 'created_by_label.*=.*trim' pages/ returns 0 matches (no inline resolution left)",
"SC-004: QG-001 + QG-002 + QG-003 + QG-006 introduce zero new failures (pre-existing issues are out of scope)"
],
"risks": [
{
"risk": "Edit actions have slightly different audit fields (some have status_changed_by, others don't)",
"mitigation": "Design AuditMetadataEnricher to accept a configurable list of field names to resolve. Each caller specifies which fields apply."
},
{
"risk": "Data endpoints have slightly different count repository interfaces",
"mitigation": "Design GridUserCountEnricher to accept a callable or repository interface for count queries, keeping it generic."
}
]
}

View File

@@ -0,0 +1,31 @@
{
"task_id": "CODE-DEDUP-ACTIONS-001",
"verdict": "pass",
"checked_criterion_ids": ["SC-001", "SC-002", "SC-003", "SC-004"],
"checks": [
{
"criterion_id": "SC-001",
"criterion": "AuditMetadataEnricher exists in lib/Service/Audit/ and is used by all 4 edit actions instead of inline created_by/modified_by resolution.",
"result": "pass",
"evidence": "execution-report.changed_files lists lib/Service/Audit/AuditMetadataEnricher.php plus all 4 edit actions as refactored; execution-report.commands includes the SC-001 rg check with result pass."
},
{
"criterion_id": "SC-002",
"criterion": "GridUserCountEnricher exists in lib/Service/Data/ and is used by all 3 data endpoints instead of inline count aggregation.",
"result": "pass",
"evidence": "execution-report.changed_files lists lib/Service/Data/GridUserCountEnricher.php plus all 3 data endpoints as refactored; execution-report.commands includes the SC-002 rg check with result pass."
},
{
"criterion_id": "SC-003",
"criterion": "No inline audit-label or user-count-aggregation logic remains in pages/ action files.",
"result": "pass",
"evidence": "execution-report.commands includes the SC-003 inline-pattern grep check with result pass ('rg created_by_label.*=.*trim pages/')."
},
{
"criterion_id": "SC-004",
"criterion": "PHPUnit, PHPStan, Architecture Contract, and CS checks pass green; zero new failures introduced by this task (pre-existing failures are out of scope).",
"result": "pass",
"evidence": "execution-report.quality_gate_results marks QG-001, QG-002, QG-003, and QG-006 as pass, and execution-report.commands marks all corresponding commands as pass; notes explicitly classify remaining issues as pre-existing and not introduced by this task."
}
]
}

View File

@@ -0,0 +1,19 @@
{
"task_id": "CODE-DEDUP-ACTIONS-001",
"verdict": "pass",
"checked_guard_ids": [
"GR-CORE-001",
"GR-CORE-002",
"GR-TEST-001",
"GR-TEST-002",
"GR-LANG-001",
"GR-LANG-002"
],
"checked_quality_gate_ids": [
"QG-001",
"QG-002",
"QG-003",
"QG-006"
],
"findings": []
}

View File

@@ -15,10 +15,12 @@ use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Repository\Search\SearchQueryRepository;
use MintyPHP\Repository\Stats\AdminStatsRepository;
use MintyPHP\Repository\Tenant\UserTenantRepository;
use MintyPHP\Repository\User\UserReadRepository;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\AddressBook\AddressBookService;
use MintyPHP\Service\AddressBook\AddressBookServicesFactory;
use MintyPHP\Service\Audit\ApiAuditService;
use MintyPHP\Service\Audit\AuditMetadataEnricher;
use MintyPHP\Service\Audit\AuditServicesFactory;
use MintyPHP\Service\Audit\FrontendTelemetryIngestService;
use MintyPHP\Service\Audit\ImportAuditService;
@@ -27,6 +29,7 @@ use MintyPHP\Service\Audit\UserLifecycleAuditService;
use MintyPHP\Service\CustomField\CustomFieldServicesFactory;
use MintyPHP\Service\CustomField\TenantCustomFieldService;
use MintyPHP\Service\CustomField\UserCustomFieldValueService;
use MintyPHP\Service\Data\GridUserCountEnricher;
use MintyPHP\Service\Import\ImportService;
use MintyPHP\Service\Import\ImportServicesFactory;
use MintyPHP\Service\Mail\MailLogService;
@@ -75,6 +78,10 @@ final class AppServicesRegistrar implements ContainerRegistrar
$container->set(AddressBookService::class, static fn (AppContainer $c): AddressBookService => $c->get(AddressBookServicesFactory::class)->createAddressBookService());
$container->set(TenantCustomFieldService::class, static fn (AppContainer $c): TenantCustomFieldService => $c->get(CustomFieldServicesFactory::class)->createTenantCustomFieldService());
$container->set(UserCustomFieldValueService::class, static fn (AppContainer $c): UserCustomFieldValueService => $c->get(CustomFieldServicesFactory::class)->createUserCustomFieldValueService());
$container->set(AuditMetadataEnricher::class, static fn (AppContainer $c): AuditMetadataEnricher => new AuditMetadataEnricher(
$c->get(UserReadRepository::class)
));
$container->set(GridUserCountEnricher::class, static fn (): GridUserCountEnricher => new GridUserCountEnricher());
$container->set(RequestInputFactory::class, static fn (): RequestInputFactory => new RequestInputFactory());
$container->set(SessionStore::class, static fn (): SessionStore => new SessionStore());
$container->set(SessionStoreInterface::class, static fn (AppContainer $c): SessionStoreInterface => $c->get(SessionStore::class));

View File

@@ -0,0 +1,42 @@
<?php
namespace MintyPHP\Service\Audit;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
class AuditMetadataEnricher
{
public function __construct(
private readonly UserReadRepositoryInterface $userReadRepository
) {
}
/**
* Resolve audit user IDs on an entity into display labels and UUIDs.
*
* For each field name in $fields the method reads `$entity[$field]` as a
* user ID, looks up the user record, and sets `{$field}_label` (full name
* with email fallback) and `{$field}_uuid` on the entity.
*
* @param array<string,mixed> $entity The entity array (modified in place).
* @param list<string> $fields Audit field names to resolve (e.g. 'created_by', 'modified_by').
*/
public function enrich(array &$entity, array $fields = ['created_by', 'modified_by']): void
{
foreach ($fields as $field) {
$userId = (int) ($entity[$field] ?? 0);
if ($userId <= 0) {
continue;
}
$user = $this->userReadRepository->find($userId);
if (!$user) {
continue;
}
$name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? ''));
$entity[$field . '_label'] = $name !== '' ? $name : ($user['email'] ?? '');
$entity[$field . '_uuid'] = $user['uuid'] ?? null;
}
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace MintyPHP\Service\Data;
class GridUserCountEnricher
{
/**
* Collect entity IDs from grid rows, batch-query total and active user
* counts, and return a lookup map keyed by entity ID.
*
* @param array[] $rows Grid result rows (each must have an 'id' key).
* @param callable(list<int>): array<int,int> $countTotal fn(ids) => [entityId => totalCount]
* @param callable(list<int>): array<int,int> $countActive fn(ids) => [entityId => activeCount]
* @return array<int, array{active_users: int, inactive_users: int}>
*/
public function computeCounts(array $rows, callable $countTotal, callable $countActive): array
{
$ids = [];
foreach ($rows as $row) {
$id = (int) ($row['id'] ?? 0);
if ($id > 0) {
$ids[] = $id;
}
}
$ids = array_values(array_unique($ids));
if (!$ids) {
return [];
}
$totalCounts = $countTotal($ids);
$activeCounts = $countActive($ids);
$result = [];
foreach ($ids as $id) {
$total = (int) ($totalCounts[$id] ?? 0);
$active = (int) ($activeCounts[$id] ?? 0);
$result[$id] = [
'active_users' => $active,
'inactive_users' => max(0, $total - $active),
];
}
return $result;
}
}

View File

@@ -29,24 +29,17 @@ $result = app(\MintyPHP\Service\Org\DepartmentService::class)->listPaged([
$settingsDefaultsGateway = app(\MintyPHP\Service\Settings\SettingsDefaultsGateway::class);
$defaultDepartmentId = $settingsDefaultsGateway->getDefaultDepartmentId();
$departmentIds = [];
foreach ($result['rows'] as $departmentRow) {
$departmentId = (int) ($departmentRow['id'] ?? 0);
if ($departmentId > 0) {
$departmentIds[] = $departmentId;
}
}
$departmentIds = array_values(array_unique($departmentIds));
$userDepartmentRepository = app(\MintyPHP\Repository\Org\UserDepartmentRepository::class);
$departmentUserCounts = $userDepartmentRepository->countUsersByDepartmentIds($departmentIds);
$departmentActiveUserCounts = $userDepartmentRepository->countActiveUsersByDepartmentIds($departmentIds);
$userCounts = app(\MintyPHP\Service\Data\GridUserCountEnricher::class)->computeCounts(
$result['rows'],
$userDepartmentRepository->countUsersByDepartmentIds(...),
$userDepartmentRepository->countActiveUsersByDepartmentIds(...)
);
$rows = [];
foreach ($result['rows'] as $row) {
$departmentId = (int) ($row['id'] ?? 0);
$usersTotal = (int) ($departmentUserCounts[$departmentId] ?? 0);
$usersActive = (int) ($departmentActiveUserCounts[$departmentId] ?? 0);
$usersInactive = max(0, $usersTotal - $usersActive);
$counts = $userCounts[$departmentId] ?? ['active_users' => 0, 'inactive_users' => 0];
$rows[] = [
'id' => $row['id'] ?? null,
'uuid' => $row['uuid'] ?? '',
@@ -55,8 +48,8 @@ foreach ($result['rows'] as $row) {
'code' => $row['code'] ?? '',
'cost_center' => $row['cost_center'] ?? '',
'active' => $row['active'] ?? 1,
'active_users' => $usersActive,
'inactive_users' => $usersInactive,
'active_users' => $counts['active_users'],
'inactive_users' => $counts['inactive_users'],
'tenants' => $row['tenant_labels'] ?? [],
'created' => dt($row['created'] ?? ''),
'modified' => dt($row['modified'] ?? ''),

View File

@@ -19,7 +19,6 @@ $currentUserId = (int) ($session['user']['id'] ?? 0);
if ($currentUserId > 0) {
app(\MintyPHP\Service\Access\PermissionService::class)->getUserPermissions($currentUserId);
}
$userAccountService = app(\MintyPHP\Service\User\UserAccountService::class);
$departmentService = app(\MintyPHP\Service\Org\DepartmentService::class);
$tenantService = app(\MintyPHP\Service\Tenant\TenantService::class);
$directoryScopeGateway = app(\MintyPHP\Service\Tenant\TenantScopeService::class);
@@ -62,24 +61,7 @@ if (!$canViewPage) {
return;
}
$creatorId = (int) ($department['created_by'] ?? 0);
if ($creatorId > 0) {
$creator = $userAccountService->findById($creatorId);
if ($creator) {
$creatorName = trim(($creator['first_name'] ?? '') . ' ' . ($creator['last_name'] ?? ''));
$department['created_by_label'] = $creatorName !== '' ? $creatorName : ($creator['email'] ?? '');
$department['created_by_uuid'] = $creator['uuid'] ?? null;
}
}
$modifierId = (int) ($department['modified_by'] ?? 0);
if ($modifierId > 0) {
$modifier = $userAccountService->findById($modifierId);
if ($modifier) {
$modifierName = trim(($modifier['first_name'] ?? '') . ' ' . ($modifier['last_name'] ?? ''));
$department['modified_by_label'] = $modifierName !== '' ? $modifierName : ($modifier['email'] ?? '');
$department['modified_by_uuid'] = $modifier['uuid'] ?? null;
}
}
app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($department);
$errorBag = formErrors();
$errors = [];

View File

@@ -22,25 +22,18 @@ $result = app(\MintyPHP\Service\Access\RoleService::class)->listPaged([
$settingsDefaultsGateway = app(\MintyPHP\Service\Settings\SettingsDefaultsGateway::class);
$defaultRoleId = $settingsDefaultsGateway->getDefaultRoleId();
$rolePermissionRepository = app(\MintyPHP\Repository\Access\RolePermissionRepository::class);
$roleIds = [];
foreach ($result['rows'] as $roleRow) {
$roleId = (int) ($roleRow['id'] ?? 0);
if ($roleId > 0) {
$roleIds[] = $roleId;
}
}
$roleIds = array_values(array_unique($roleIds));
$userRoleRepository = app(\MintyPHP\Repository\Access\UserRoleRepository::class);
$roleUserCounts = $userRoleRepository->countUsersByRoleIds($roleIds);
$roleActiveUserCounts = $userRoleRepository->countActiveUsersByRoleIds($roleIds);
$rolePermissionCounts = $rolePermissionRepository->countPermissionsByRoleIds($roleIds);
$userCounts = app(\MintyPHP\Service\Data\GridUserCountEnricher::class)->computeCounts(
$result['rows'],
$userRoleRepository->countUsersByRoleIds(...),
$userRoleRepository->countActiveUsersByRoleIds(...)
);
$rolePermissionCounts = $rolePermissionRepository->countPermissionsByRoleIds(array_keys($userCounts));
$rows = [];
foreach ($result['rows'] as $row) {
$roleId = (int) ($row['id'] ?? 0);
$usersTotal = (int) ($roleUserCounts[$roleId] ?? 0);
$usersActive = (int) ($roleActiveUserCounts[$roleId] ?? 0);
$usersInactive = max(0, $usersTotal - $usersActive);
$counts = $userCounts[$roleId] ?? ['active_users' => 0, 'inactive_users' => 0];
$rows[] = [
'id' => $row['id'] ?? null,
'uuid' => $row['uuid'] ?? '',
@@ -48,8 +41,8 @@ foreach ($result['rows'] as $row) {
'description' => $row['description'] ?? '',
'code' => $row['code'] ?? '',
'active' => (int) ($row['active'] ?? 1),
'active_users' => $usersActive,
'inactive_users' => $usersInactive,
'active_users' => $counts['active_users'],
'inactive_users' => $counts['inactive_users'],
'permission_count' => (int) ($rolePermissionCounts[$roleId] ?? 0),
'created' => dt($row['created'] ?? ''),
'modified' => dt($row['modified'] ?? ''),

View File

@@ -20,8 +20,6 @@ $permissionRepository = app(\MintyPHP\Repository\Access\PermissionRepository::cl
$rolePermissionRepository = app(\MintyPHP\Repository\Access\RolePermissionRepository::class);
$userRoleRepository = app(\MintyPHP\Repository\Access\UserRoleRepository::class);
$userWriteRepository = app(\MintyPHP\Repository\User\UserWriteRepository::class);
$userAccountService = app(\MintyPHP\Service\User\UserAccountService::class);
$uuid = trim((string) ($id ?? ''));
$editTarget = requestPathWithReturnTarget("admin/roles/edit/{$uuid}", $returnTarget);
$role = $uuid !== '' ? app(\MintyPHP\Service\Access\RoleService::class)->findByUuid($uuid) : null;
@@ -42,24 +40,7 @@ $capabilities = $contextDecision->attribute('capabilities', []);
$canUpdateRole = is_array($capabilities) ? (bool) ($capabilities['can_edit_role'] ?? false) : false;
$canDeleteRole = is_array($capabilities) ? (bool) ($capabilities['can_delete_role'] ?? false) : false;
$creatorId = (int) ($role['created_by'] ?? 0);
if ($creatorId > 0) {
$creator = $userAccountService->findById($creatorId);
if ($creator) {
$creatorName = trim(($creator['first_name'] ?? '') . ' ' . ($creator['last_name'] ?? ''));
$role['created_by_label'] = $creatorName !== '' ? $creatorName : ($creator['email'] ?? '');
$role['created_by_uuid'] = $creator['uuid'] ?? null;
}
}
$modifierId = (int) ($role['modified_by'] ?? 0);
if ($modifierId > 0) {
$modifier = $userAccountService->findById($modifierId);
if ($modifier) {
$modifierName = trim(($modifier['first_name'] ?? '') . ' ' . ($modifier['last_name'] ?? ''));
$role['modified_by_label'] = $modifierName !== '' ? $modifierName : ($modifier['email'] ?? '');
$role['modified_by_uuid'] = $modifier['uuid'] ?? null;
}
}
app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($role);
$errorBag = formErrors();
$errors = [];

View File

@@ -25,23 +25,19 @@ $tenantAvatarService = app(\MintyPHP\Service\Tenant\TenantAvatarService::class);
$settingsDefaultsGateway = app(\MintyPHP\Service\Settings\SettingsDefaultsGateway::class);
$settingsAppGateway = app(\MintyPHP\Service\Settings\SettingsAppGateway::class);
$gridUserCountEnricher = app(\MintyPHP\Service\Data\GridUserCountEnricher::class);
$fetchRows = static function (
array $tenantRows,
array $themes,
string $globalDefaultTheme
) use ($userTenantRepository, $tenantMicrosoftAuthRepository, $tenantAvatarService, $settingsDefaultsGateway): array {
$tenantIds = [];
foreach ($tenantRows as $tenantRow) {
$tenantId = (int) ($tenantRow['id'] ?? 0);
if ($tenantId > 0) {
$tenantIds[] = $tenantId;
}
}
$tenantIds = array_values(array_unique($tenantIds));
) use ($userTenantRepository, $tenantMicrosoftAuthRepository, $tenantAvatarService, $settingsDefaultsGateway, $gridUserCountEnricher): array {
$userCounts = $gridUserCountEnricher->computeCounts(
$tenantRows,
$userTenantRepository->countUsersByTenantIds(...),
$userTenantRepository->countActiveUsersByTenantIds(...)
);
$tenantIds = array_keys($userCounts);
$tenantSsoMap = $tenantMicrosoftAuthRepository->listByTenantIds($tenantIds);
$tenantUserCounts = $userTenantRepository->countUsersByTenantIds($tenantIds);
$tenantActiveUserCounts = $userTenantRepository->countActiveUsersByTenantIds($tenantIds);
$defaultTenantId = $settingsDefaultsGateway->getDefaultTenantId();
$rows = [];
@@ -67,9 +63,7 @@ $fetchRows = static function (
$tenantSso = $tenantSsoMap[$tenantId] ?? null;
$ssoEnabled = !empty($tenantSso['enabled']);
$ssoMicrosoftOnly = $ssoEnabled && !empty($tenantSso['enforce_microsoft_login']);
$usersTotal = (int) ($tenantUserCounts[$tenantId] ?? 0);
$usersActive = (int) ($tenantActiveUserCounts[$tenantId] ?? 0);
$usersInactive = max(0, $usersTotal - $usersActive);
$counts = $userCounts[$tenantId] ?? ['active_users' => 0, 'inactive_users' => 0];
$tenantStatus = TenantStatus::normalizeOr((string) ($row['status'] ?? ''), TenantStatus::Active);
$tenantUuid = (string) ($row['uuid'] ?? '');
@@ -91,8 +85,8 @@ $fetchRows = static function (
'enabled' => $ssoEnabled ? 1 : 0,
'microsoft_only' => $ssoMicrosoftOnly ? 1 : 0,
],
'active_users' => $usersActive,
'inactive_users' => $usersInactive,
'active_users' => $counts['active_users'],
'inactive_users' => $counts['inactive_users'],
'created' => dt($row['created'] ?? ''),
'modified' => dt($row['modified'] ?? ''),
'has_avatar' => $tenantUuid !== '' && $tenantAvatarService->hasAvatar($tenantUuid),

View File

@@ -19,8 +19,6 @@ $currentUserId = (int) ($session['user']['id'] ?? 0);
if ($currentUserId > 0) {
app(\MintyPHP\Service\Access\PermissionService::class)->getUserPermissions($currentUserId);
}
$userAccountService = app(\MintyPHP\Service\User\UserAccountService::class);
$uuid = trim((string) ($id ?? ''));
$editTarget = requestPathWithReturnTarget("admin/tenants/edit/{$uuid}", $returnTarget);
$tenant = $uuid !== '' ? app(\MintyPHP\Service\Tenant\TenantService::class)->findByUuid($uuid) : null;
@@ -69,33 +67,7 @@ $customFieldDefinitions = $canManageCustomFields
: [];
$ssoConfig = $canManageSso ? $tenantSsoService->getTenantMicrosoftAuth($tenantId) : [];
$ssoUiState = $canManageSso ? $tenantSsoService->buildMicrosoftUiState($tenantId, $ssoConfig) : [];
$creatorId = (int) ($tenant['created_by'] ?? 0);
if ($creatorId > 0) {
$creator = $userAccountService->findById($creatorId);
if ($creator) {
$creatorName = trim(($creator['first_name'] ?? '') . ' ' . ($creator['last_name'] ?? ''));
$tenant['created_by_label'] = $creatorName !== '' ? $creatorName : ($creator['email'] ?? '');
$tenant['created_by_uuid'] = $creator['uuid'] ?? null;
}
}
$modifierId = (int) ($tenant['modified_by'] ?? 0);
if ($modifierId > 0) {
$modifier = $userAccountService->findById($modifierId);
if ($modifier) {
$modifierName = trim(($modifier['first_name'] ?? '') . ' ' . ($modifier['last_name'] ?? ''));
$tenant['modified_by_label'] = $modifierName !== '' ? $modifierName : ($modifier['email'] ?? '');
$tenant['modified_by_uuid'] = $modifier['uuid'] ?? null;
}
}
$statusChangedById = (int) ($tenant['status_changed_by'] ?? 0);
if ($statusChangedById > 0) {
$statusChanger = $userAccountService->findById($statusChangedById);
if ($statusChanger) {
$statusChangerName = trim(($statusChanger['first_name'] ?? '') . ' ' . ($statusChanger['last_name'] ?? ''));
$tenant['status_changed_by_label'] = $statusChangerName !== '' ? $statusChangerName : ($statusChanger['email'] ?? '');
$tenant['status_changed_by_uuid'] = $statusChanger['uuid'] ?? null;
}
}
app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($tenant, ['created_by', 'modified_by', 'status_changed_by']);
$errorBag = formErrors();
$errors = [];

View File

@@ -87,33 +87,7 @@ if (!$canViewPage) {
}
if ($canViewUserMeta || $canViewUserAudit) {
$creatorId = (int) ($user['created_by'] ?? 0);
if ($creatorId > 0) {
$creator = $userAccountService->findById($creatorId);
if ($creator) {
$creatorName = trim(($creator['first_name'] ?? '') . ' ' . ($creator['last_name'] ?? ''));
$user['created_by_label'] = $creatorName !== '' ? $creatorName : ($creator['email'] ?? '');
$user['created_by_uuid'] = $creator['uuid'] ?? null;
}
}
$modifierId = (int) ($user['modified_by'] ?? 0);
if ($modifierId > 0) {
$modifier = $userAccountService->findById($modifierId);
if ($modifier) {
$modifierName = trim(($modifier['first_name'] ?? '') . ' ' . ($modifier['last_name'] ?? ''));
$user['modified_by_label'] = $modifierName !== '' ? $modifierName : ($modifier['email'] ?? '');
$user['modified_by_uuid'] = $modifier['uuid'] ?? null;
}
}
$activeChangedById = (int) ($user['active_changed_by'] ?? 0);
if ($activeChangedById > 0) {
$activeChanger = $userAccountService->findById($activeChangedById);
if ($activeChanger) {
$activeChangerName = trim(($activeChanger['first_name'] ?? '') . ' ' . ($activeChanger['last_name'] ?? ''));
$user['active_changed_by_label'] = $activeChangerName !== '' ? $activeChangerName : ($activeChanger['email'] ?? '');
$user['active_changed_by_uuid'] = $activeChanger['uuid'] ?? null;
}
}
app(\MintyPHP\Service\Audit\AuditMetadataEnricher::class)->enrich($user, ['created_by', 'modified_by', 'active_changed_by']);
}
$errorBag = formErrors();

View File

@@ -0,0 +1,92 @@
<?php
namespace MintyPHP\Tests\Service\Audit;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Service\Audit\AuditMetadataEnricher;
use PHPUnit\Framework\TestCase;
class AuditMetadataEnricherTest extends TestCase
{
private function newEnricher(UserReadRepositoryInterface $userReadRepository): AuditMetadataEnricher
{
return new AuditMetadataEnricher($userReadRepository);
}
public function testEnrichResolvesCreatedByAndModifiedBy(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->method('find')->willReturnMap([
[10, ['first_name' => 'Alice', 'last_name' => 'Smith', 'email' => 'alice@example.com', 'uuid' => 'uuid-10']],
[20, ['first_name' => 'Bob', 'last_name' => 'Jones', 'email' => 'bob@example.com', 'uuid' => 'uuid-20']],
]);
$entity = ['created_by' => 10, 'modified_by' => 20];
$this->newEnricher($repo)->enrich($entity);
$this->assertSame('Alice Smith', $entity['created_by_label']);
$this->assertSame('uuid-10', $entity['created_by_uuid']);
$this->assertSame('Bob Jones', $entity['modified_by_label']);
$this->assertSame('uuid-20', $entity['modified_by_uuid']);
}
public function testEnrichFallsBackToEmailWhenNameEmpty(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->method('find')->willReturn(['first_name' => '', 'last_name' => '', 'email' => 'no-name@example.com', 'uuid' => 'uuid-x']);
$entity = ['created_by' => 5];
$this->newEnricher($repo)->enrich($entity, ['created_by']);
$this->assertSame('no-name@example.com', $entity['created_by_label']);
$this->assertSame('uuid-x', $entity['created_by_uuid']);
}
public function testEnrichSkipsZeroAndNegativeIds(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->expects($this->never())->method('find');
$entity = ['created_by' => 0, 'modified_by' => -1];
$this->newEnricher($repo)->enrich($entity);
$this->assertArrayNotHasKey('created_by_label', $entity);
$this->assertArrayNotHasKey('modified_by_label', $entity);
}
public function testEnrichSkipsMissingFields(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->expects($this->never())->method('find');
$entity = [];
$this->newEnricher($repo)->enrich($entity, ['created_by', 'modified_by']);
$this->assertArrayNotHasKey('created_by_label', $entity);
}
public function testEnrichHandlesUserNotFound(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->method('find')->willReturn(null);
$entity = ['created_by' => 999];
$this->newEnricher($repo)->enrich($entity, ['created_by']);
$this->assertArrayNotHasKey('created_by_label', $entity);
}
public function testEnrichWithCustomFields(): void
{
$repo = $this->createMock(UserReadRepositoryInterface::class);
$repo->method('find')->willReturn(['first_name' => 'Admin', 'last_name' => '', 'email' => 'admin@co.com', 'uuid' => 'uuid-a']);
$entity = ['status_changed_by' => 7, 'active_changed_by' => 7];
$this->newEnricher($repo)->enrich($entity, ['status_changed_by', 'active_changed_by']);
$this->assertSame('Admin', $entity['status_changed_by_label']);
$this->assertSame('uuid-a', $entity['status_changed_by_uuid']);
$this->assertSame('Admin', $entity['active_changed_by_label']);
$this->assertSame('uuid-a', $entity['active_changed_by_uuid']);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace MintyPHP\Tests\Service\Data;
use MintyPHP\Service\Data\GridUserCountEnricher;
use PHPUnit\Framework\TestCase;
class GridUserCountEnricherTest extends TestCase
{
private function enricher(): GridUserCountEnricher
{
return new GridUserCountEnricher();
}
public function testComputeCountsWithTypicalRows(): void
{
$rows = [
['id' => 1, 'description' => 'A'],
['id' => 2, 'description' => 'B'],
];
$countTotal = static fn (array $ids): array => [1 => 5, 2 => 3];
$countActive = static fn (array $ids): array => [1 => 3, 2 => 1];
$result = $this->enricher()->computeCounts($rows, $countTotal, $countActive);
$this->assertSame(['active_users' => 3, 'inactive_users' => 2], $result[1]);
$this->assertSame(['active_users' => 1, 'inactive_users' => 2], $result[2]);
}
public function testComputeCountsWithEmptyRows(): void
{
$called = false;
$countTotal = static function () use (&$called): array {
$called = true;
return [];
};
$countActive = static fn (): array => [];
$result = $this->enricher()->computeCounts([], $countTotal, $countActive);
$this->assertSame([], $result);
$this->assertFalse($called, 'Count callables should not be invoked for empty rows');
}
public function testComputeCountsDeduplicatesIds(): void
{
$rows = [
['id' => 1],
['id' => 1],
['id' => 2],
];
$receivedIds = null;
$countTotal = static function (array $ids) use (&$receivedIds): array {
$receivedIds = $ids;
return [1 => 10, 2 => 5];
};
$countActive = static fn (array $ids): array => [1 => 8, 2 => 3];
$this->enricher()->computeCounts($rows, $countTotal, $countActive);
$this->assertSame([1, 2], $receivedIds);
}
public function testComputeCountsSkipsZeroIds(): void
{
$rows = [
['id' => 0],
['id' => -1],
['id' => 3],
];
$receivedIds = null;
$countTotal = static function (array $ids) use (&$receivedIds): array {
$receivedIds = $ids;
return [3 => 2];
};
$countActive = static fn (array $ids): array => [3 => 1];
$result = $this->enricher()->computeCounts($rows, $countTotal, $countActive);
$this->assertSame([3], $receivedIds);
$this->assertCount(1, $result);
$this->assertSame(['active_users' => 1, 'inactive_users' => 1], $result[3]);
}
public function testComputeCountsWithZeroCounts(): void
{
$rows = [['id' => 5]];
$countTotal = static fn (array $ids): array => [];
$countActive = static fn (array $ids): array => [];
$result = $this->enricher()->computeCounts($rows, $countTotal, $countActive);
$this->assertSame(['active_users' => 0, 'inactive_users' => 0], $result[5]);
}
public function testInactiveNeverNegative(): void
{
$rows = [['id' => 1]];
$countTotal = static fn (array $ids): array => [1 => 2];
$countActive = static fn (array $ids): array => [1 => 5];
$result = $this->enricher()->computeCounts($rows, $countTotal, $countActive);
$this->assertSame(0, $result[1]['inactive_users']);
}
}