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

@@ -4,10 +4,8 @@ $accountName = currentUserDisplayName();
$accountTooltip = $accountName !== '' ? $accountName : t('Account');
$layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
$hasAdminPanel = layoutHasAdminPanel($layoutAuth);
$canViewAddressBook = (bool) ($layoutAuth['can_view_address_book'] ?? false);
$addressBookUrl = lurl('address-book');
?>
<aside class="aside-icon-bar" data-aside-storage="app-sidebar-panel">
<aside class="aside-icon-bar" data-app-component="aside-panels" data-aside-storage="app-sidebar-panel">
<nav>
<ul class="aside-icon-group" role="tablist" aria-orientation="vertical">
<li>
@@ -24,16 +22,35 @@ $addressBookUrl = lurl('address-book');
<i class="bi bi-search"></i>
</button>
</li>
<?php if ($canViewAddressBook): ?>
<?php
// ── Module-contributed aside tabs (aside.tab_panel slot) ──
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
$moduleTabSlots = is_array($moduleSlots['aside.tab_panel'] ?? null) ? $moduleSlots['aside.tab_panel'] : [];
foreach ($moduleTabSlots as $slot):
if (!is_array($slot)) { continue; }
$slotKey = $slot['key'] ?? '';
$slotLabel = $slot['label'] ?? '';
$slotIcon = $slot['icon'] ?? 'bi-puzzle';
$slotHref = $slot['href'] ?? '';
$slotPermission = $slot['permission'] ?? '';
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) { continue; }
// Resolve relative route keys via lurl()
if ($slotHref !== '' && !str_starts_with($slotHref, '/') && !str_starts_with($slotHref, 'http')) {
$slotHref = lurl($slotHref);
}
?>
<li>
<button type="button" id="aside-tab-people" data-aside-target="people"
data-aside-href="<?php e($addressBookUrl); ?>" role="tab" aria-selected="false"
aria-controls="aside-panel-people" aria-label="<?php e(t('Address book')); ?>"
data-tooltip="<?php e(t('Address book')); ?>" data-tooltip-pos="right">
<i class="bi bi-people"></i>
<button type="button" id="aside-tab-<?php e($slotKey); ?>"
data-aside-target="<?php e($slotKey); ?>"
<?php if ($slotHref !== ''): ?>data-aside-href="<?php e($slotHref); ?>"<?php endif; ?>
role="tab" aria-selected="false"
aria-controls="aside-panel-<?php e($slotKey); ?>"
aria-label="<?php e(t($slotLabel)); ?>"
data-tooltip="<?php e(t($slotLabel)); ?>" data-tooltip-pos="right">
<i class="bi <?php e($slotIcon); ?>"></i>
</button>
</li>
<?php endif; ?>
<?php endforeach; ?>
<li>
<button type="button" id="aside-tab-bookmarks" data-aside-target="bookmarks" role="tab"
aria-selected="false" aria-controls="aside-panel-bookmarks"

View File

@@ -20,7 +20,6 @@ $canViewSystemAudit = (bool) ($layoutAuth['can_view_system_audit'] ?? false);
$canViewImportsAudit = (bool) ($layoutAuth['can_view_imports_audit'] ?? false);
$canViewUserLifecycleAudit = (bool) ($layoutAuth['can_view_user_lifecycle_audit'] ?? false);
$canViewStats = (bool) ($layoutAuth['can_view_stats'] ?? false);
$canViewAddressBook = (bool) ($layoutAuth['can_view_address_book'] ?? false);
$docsDefaultSlug = DocsCatalogService::defaultSlug();
$docsDefaultPath = 'admin/docs/' . $docsDefaultSlug;
$hasOrganization = $canViewTenants || $canViewDepartments || $canViewUsers;
@@ -254,18 +253,10 @@ $tenantAvatar = is_array($layoutNav['tenantAvatar'] ?? null) ? $layoutNav['tenan
$tenantUuid = trim((string) ($tenantAvatar['uuid'] ?? ''));
$tenantName = trim((string) ($tenantAvatar['name'] ?? ''));
$hasTenantAvatar = !empty($tenantAvatar['hasAvatar']);
$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'] : [];
$csrfKey = trim((string) ($layoutNav['csrfKey'] ?? \MintyPHP\Session::$csrfSessionKey));
$csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
?>
<aside class="app-sidebar" id="app-sidebar">
<aside class="app-sidebar" id="app-sidebar" data-app-component="sidebar-toggle">
<?php if ($currentTenant): ?>
<div class="app-sidebar-tenant-logo">
<a href="<?php e(lurl('')); ?>">
@@ -309,84 +300,40 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
</nav>
<?php endif; ?>
<?php if ($canViewAddressBook): ?>
<?php $addressBookActive = navActive('address-book', true); ?>
<nav id="aside-panel-people" class="app-sidebar-panel" data-aside-panel="people"
data-aside-details-storage="aside-people-tenant"
data-aside-title="<?php e(t('Address book')); ?>" role="tabpanel" aria-labelledby="aside-tab-people" hidden>
<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>
<?php
// ── Module-contributed aside panels (aside.tab_panel slot) ──
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
$modulePanelSlots = is_array($moduleSlots['aside.tab_panel'] ?? null) ? $moduleSlots['aside.tab_panel'] : [];
foreach ($modulePanelSlots as $panelSlot):
if (!is_array($panelSlot)) { continue; }
$panelKey = $panelSlot['key'] ?? '';
$panelLabel = $panelSlot['label'] ?? '';
$panelTemplate = $panelSlot['panel_template'] ?? '';
$panelPermission = $panelSlot['permission'] ?? '';
if ($panelPermission !== '' && empty($layoutAuth[$panelPermission])) { continue; }
if ($panelKey === '' || $panelTemplate === '') { continue; }
?>
<?php
$panelDetailsStorage = $panelSlot['details_storage'] ?? '';
$panelDetailsOpenActive = !empty($panelSlot['details_open_active']);
?>
<nav id="aside-panel-<?php e($panelKey); ?>" class="app-sidebar-panel"
data-aside-panel="<?php e($panelKey); ?>"
<?php if ($panelDetailsStorage !== ''): ?>data-aside-details-storage="<?php e($panelDetailsStorage); ?>"<?php endif; ?>
<?php if ($panelDetailsOpenActive): ?>data-aside-details-open-active="1"<?php endif; ?>
data-aside-title="<?php e(t($panelLabel)); ?>"
role="tabpanel" aria-labelledby="aside-tab-<?php e($panelKey); ?>" hidden>
<?php
// Include the module-provided panel template
$panelTemplatePath = $panelTemplate;
if (is_file($panelTemplatePath)) {
include $panelTemplatePath;
}
?>
</nav>
<?php endif; ?>
<?php endforeach; ?>
<div id="aside-panel-search" class="app-sidebar-panel" data-aside-panel="search"
<div id="aside-panel-search" class="app-sidebar-panel" data-app-component="global-search" data-aside-panel="search"
data-aside-title="<?php e(t('Search')); ?>" role="tabpanel" aria-labelledby="aside-tab-search" hidden>
<form class="app-search">
<input type="search" name="side-search" id="side-search" placeholder="<?php e(t('Search...')); ?>"
@@ -545,7 +492,7 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
$bookmarkUngrouped = is_array($bookmarkData['ungrouped'] ?? null) ? $bookmarkData['ungrouped'] : [];
$currentPath = BookmarkUrlNormalizer::canonicalizeRequestUri((string) ($_SERVER['REQUEST_URI'] ?? ''), localeBase());
?>
<nav id="aside-panel-bookmarks" class="app-sidebar-panel" data-aside-panel="bookmarks"
<nav id="aside-panel-bookmarks" class="app-sidebar-panel" data-app-component="bookmark-panel" data-aside-panel="bookmarks"
data-aside-title="<?php e(t('Bookmarks')); ?>"
data-aside-details-storage="aside-bookmarks-groups-v1"
data-bookmark-reorder-url="<?php e(lurl('bookmarks/reorder-data')); ?>"