feat: introduce module system and extract address book as first module (MODULAR-MONOLITH-V1-001)

Add a module kernel (ModuleManifest, ModuleRegistry) that allows modules to
contribute routes, UI slots, search providers, layout context, session
lifecycle, and permissions. Modules are activated via config/modules.php or
APP_ENABLED_MODULES env variable. Conflicts (duplicate routes, permissions,
slot keys) cause a fail-fast with a clear error message.

Extract the address book from hardcoded Core integration points into the
first module (modules/addressbook/). The module provides:
- Aside icon-bar tab + People panel via UI slot system
- Global search resource via AddressBookSearchProvider
- Layout context data via AddressBookLayoutProvider
- Session lifecycle via AddressBookSessionProvider

Core cleanup removes address-book hardcodings from SearchSqlResourceProvider,
SearchUiMetaProvider, SearchItemMapperProvider, appBuildLayoutNavContext(),
and the aside templates. Permissions (ADDRESS_BOOK_VIEW) and business logic
(AddressBookService) remain in Core as they gate general user visibility.

Includes 38 new tests (894 total), PHPStan level 5 clean, and architecture
tests verifying zero hardcoded address-book references in search/templates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 15:43:25 +01:00
parent d9805c45d3
commit c364e2b46d
33 changed files with 2639 additions and 167 deletions

View File

@@ -2,18 +2,8 @@
namespace MintyPHP\Support\Search;
use MintyPHP\Service\User\UserAvatarService;
class SearchItemMapperProvider
{
/** @var (callable(): UserAvatarService)|null */
private static $userAvatarServiceResolver = null;
public static function configure(callable $userAvatarServiceResolver): void
{
self::$userAvatarServiceResolver = $userAvatarServiceResolver;
}
public static function mapPreviewItem(string $key, array $data, string $query): ?array
{
if ($key === 'users') {
@@ -23,13 +13,6 @@ class SearchItemMapperProvider
$label = $label === '' ? $email : ($label . ' — ' . $email);
}
$url = lurl('admin/users/edit/' . ($data['uuid'] ?? '')) . '?search=' . urlencode($query);
} elseif ($key === 'address-book') {
$label = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? ''));
$email = trim((string) ($data['email'] ?? ''));
if ($email !== '') {
$label = $label === '' ? $email : ($label . ' — ' . $email);
}
$url = lurl('address-book/view/' . ($data['uuid'] ?? '')) . '?search=' . urlencode($query);
} elseif ($key === 'permissions') {
$label = (string) ($data['description'] ?? '');
$url = lurl('admin/permissions/edit/' . ($data['id'] ?? '')) . '?search=' . urlencode($query);
@@ -99,15 +82,6 @@ class SearchItemMapperProvider
$title = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? ''));
$description = (string) ($data['email'] ?? '');
$url = lurl('admin/users/edit/' . ($data['uuid'] ?? ''));
} elseif ($key === 'address-book') {
$title = trim(($data['first_name'] ?? '') . ' ' . ($data['last_name'] ?? ''));
$description = (string) ($data['email'] ?? '');
$uuid = (string) ($data['uuid'] ?? '');
$url = lurl('address-book/view/' . $uuid);
$userAvatarService = self::userAvatarService();
if ($uuid !== '' && $userAvatarService->hasAvatar($uuid)) {
$image = lurl('admin/users/avatar-file') . '?uuid=' . urlencode($uuid) . '&size=64';
}
} elseif ($key === 'permissions') {
$title = (string) ($data['key'] ?? '');
$description = (string) ($data['description'] ?? '');
@@ -170,18 +144,5 @@ class SearchItemMapperProvider
];
}
private static function userAvatarService(): UserAvatarService
{
if (!is_callable(self::$userAvatarServiceResolver)) {
throw new \RuntimeException('SearchItemMapperProvider is not configured for dependency: ' . UserAvatarService::class);
}
$service = (self::$userAvatarServiceResolver)();
if (!$service instanceof UserAvatarService) {
throw new \RuntimeException('SearchItemMapperProvider resolver returned invalid dependency: ' . UserAvatarService::class);
}
return $service;
}
}