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

@@ -0,0 +1,57 @@
<?php
/**
* Address Book module manifest.
*
* Contributes the "People" aside tab/panel, global search section,
* layout context data, and session lifecycle.
*
* Permissions (address_book.view) remain in Core PermissionService because
* they gate general user-visibility in UserAuthorizationPolicy, not just
* address-book access. Asset groups remain in config/assets.php because the
* asset pipeline does not yet consume ModuleRegistry::getAssetGroups().
*/
return [
'id' => 'addressbook',
'version' => '1.0.0',
'enabled_by_default' => false,
'load_order' => 10,
'routes' => [],
'public_paths' => [],
'container_registrars' => [],
'ui_slots' => [
'aside.tab_panel' => [
[
'key' => 'people',
'label' => 'Address book',
'icon' => 'bi-people',
'href' => 'address-book',
'permission' => 'can_view_address_book',
'panel_template' => __DIR__ . '/templates/aside-people-panel.phtml',
'details_storage' => 'aside-people-tenant',
'details_open_active' => true,
],
],
],
'search_resources' => [
\MintyPHP\Module\AddressBook\Providers\AddressBookSearchProvider::class,
],
'asset_groups' => [],
'scheduler_jobs' => [],
'layout_context_providers' => [
\MintyPHP\Module\AddressBook\Providers\AddressBookLayoutProvider::class,
],
'session_providers' => [
\MintyPHP\Module\AddressBook\Providers\AddressBookSessionProvider::class,
],
'permissions' => [],
'migrations_path' => 'db/updates',
];

View File

@@ -0,0 +1,89 @@
<?php
/**
* Address book aside panel — people browser with tenant/department hierarchy.
*
* Rendered by the module slot system. The outer <nav> with id, role, aria
* attributes is created by the slot renderer in app-main-aside.phtml.
* This template provides only the inner content.
*
* Available in scope via include: $layoutNav, $layoutAuth, $viewAuth
*/
$addressBook = is_array($layoutNav['addressBook'] ?? null) ? $layoutNav['addressBook'] : [];
$addressBookUrl = trim((string) ($addressBook['url'] ?? lurl('address-book')));
$activeAddressSearch = trim((string) ($addressBook['activeSearch'] ?? ''));
$activeAddressTenants = is_array($addressBook['activeTenants'] ?? null) ? $addressBook['activeTenants'] : [];
$activeAddressDepartments = is_array($addressBook['activeDepartments'] ?? null) ? $addressBook['activeDepartments'] : [];
$activeAddressRoles = is_array($addressBook['activeRoles'] ?? null) ? $addressBook['activeRoles'] : [];
$activeAddressCustomFilters = is_array($addressBook['activeCustomFilters'] ?? null) ? $addressBook['activeCustomFilters'] : [];
$peopleGroups = is_array($addressBook['peopleGroups'] ?? null) ? $addressBook['peopleGroups'] : [];
$addressBookActive = navActive('address-book', true);
?>
<ul>
<?php if (!$peopleGroups): ?>
<li>
<a href="<?php e($addressBookUrl); ?>" class="<?php e($addressBookActive['class'] ?? ''); ?>"
<?php echo $addressBookActive['aria'] ?? ''; ?>>
<?php e(t('Address book')); ?>
</a>
</li>
<?php else: ?>
<?php foreach ($peopleGroups as $index => $group): ?>
<?php
$tenant = $group['tenant'] ?? [];
$departments = $group['departments'] ?? [];
$tenantUuid = (string) ($tenant['uuid'] ?? '');
$tenantName = (string) ($tenant['description'] ?? '');
if ($tenantUuid === '') {
continue;
}
$baseHref = $addressBookUrl . '?tenants=' . urlencode($tenantUuid);
$isActiveTenant = $addressBookActive['isActive']
&& !$activeAddressDepartments
&& count($activeAddressTenants) === 1
&& $activeAddressTenants[0] === $tenantUuid;
?>
<li class="app-sidebar-group">
<small>
<a href="<?php e($baseHref); ?>" class="<?php e($isActiveTenant ? 'active' : 'muted'); ?>"
<?php echo $isActiveTenant ? 'aria-current="page"' : ''; ?>>
<?php e($tenantName); ?>
</a>
</small>
<ul>
<?php if (!$departments): ?>
<li>
<?php
$emptyState = [
'message' => t('No departments'),
'size' => 'small',
'align' => 'center',
];
require templatePath('partials/app-empty-state.phtml');
?>
</li>
<?php else: ?>
<?php foreach ($departments as $department): ?>
<?php
$departmentId = (int) ($department['id'] ?? 0);
if ($departmentId <= 0) {
continue;
}
$departmentName = (string) ($department['description'] ?? '');
$href = $baseHref . '&departments=' . $departmentId;
$isActive = $addressBookActive['isActive']
&& in_array($tenantUuid, $activeAddressTenants, true)
&& in_array($departmentId, $activeAddressDepartments, true);
?>
<li>
<a href="<?php e($href); ?>" class="<?php e($isActive ? 'active' : ''); ?>"
<?php echo $isActive ? 'aria-current="page"' : ''; ?>>
<?php e($departmentName); ?>
</a>
</li>
<?php endforeach; ?>
<?php endif; ?>
</ul>
</li>
<?php endforeach; ?>
<?php endif; ?>
</ul>