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,40 @@
<?php
namespace MintyPHP\Module\AddressBook\Providers;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
/**
* Provides address book data for the layout navigation context.
*
* Contributes the 'addressBook' key to $layoutNav with URL, active filters,
* and people groups from the session.
*/
final class AddressBookLayoutProvider implements LayoutContextProvider
{
public function provide(array $session, AppContainer $container): array
{
// Read query params for active filter state
$query = [];
try {
$query = requestInput()->queryAll();
} catch (\Throwable) {
// fail-open: may not be available in CLI context
}
return [
'addressBook' => [
'url' => lurl('address-book'),
'activeSearch' => trim((string) ($query['search'] ?? '')),
'activeTenants' => appNormalizeStringList($query['tenants'] ?? ''),
'activeDepartments' => appNormalizePositiveIntList($query['departments'] ?? ''),
'activeRoles' => appNormalizePositiveIntList($query['roles'] ?? ''),
'activeCustomFilters' => appNormalizeAddressBookCustomFilterQuery($query),
'peopleGroups' => is_array($session['available_departments_by_tenant'] ?? null)
? $session['available_departments_by_tenant']
: [],
],
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace MintyPHP\Module\AddressBook\Providers;
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
/**
* Provides the address-book search resource for the global search.
*/
final class AddressBookSearchProvider implements SearchResourceProvider
{
public function resources(): array
{
return [
'address-book' => [
'label' => t('Address book'),
'permission' => 'address_book.view',
'count_sql' => "select count(*) from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}}",
'preview_sql' => "select uuid, first_name, last_name, email from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}} order by last_name, first_name limit ?",
'result_sql' => "select uuid, first_name, last_name, email from users where active = 1 and (first_name like ? escape '\\\\' or last_name like ? escape '\\\\' or email like ? escape '\\\\') {{tenantFilter}} order by last_name, first_name",
'tenant_filter' => 'and exists (select 1 from user_tenants ut where ut.user_id = users.id and ut.tenant_id in (???))',
],
];
}
public function mapResultItem(string $resourceKey, array $row): ?array
{
if ($resourceKey !== 'address-book') {
return null;
}
$title = trim(($row['first_name'] ?? '') . ' ' . ($row['last_name'] ?? ''));
$subtitle = (string) ($row['email'] ?? '');
$uuid = (string) ($row['uuid'] ?? '');
if ($title === '' && $subtitle === '') {
return null;
}
return [
'title' => $title !== '' ? $title : $subtitle,
'subtitle' => $subtitle,
'url' => lurl('address-book/view/' . $uuid),
'icon' => 'bi-people',
];
}
public function listUrl(string $resourceKey, string $encodedSearch): string
{
if ($resourceKey !== 'address-book') {
return '';
}
return lurl('address-book') . '?search=' . $encodedSearch;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace MintyPHP\Module\AddressBook\Providers;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\SessionProvider;
/**
* Populates session with department-by-tenant hierarchy data used by the
* address book aside panel.
*
* In V1 this data is already populated by the core auth flow (loadTenantDataIntoSession).
* This provider is a placeholder that will take over population once the core
* address book hardcodings are fully removed.
*/
final class AddressBookSessionProvider implements SessionProvider
{
public function populate(array $user, AppContainer $container): void
{
// V1: available_departments_by_tenant is still populated by core's
// AuthService::loadTenantDataIntoSession(). This provider is registered
// so the contract is satisfied and the session hook is ready for V2
// when the core delegation is fully removed.
}
public function clear(): void
{
unset($_SESSION['available_departments_by_tenant']);
}
}