refactor: align addressbook module to MintyPHP\Module\AddressBook namespace

Migrates addressbook service classes from core namespace
(MintyPHP\Service\AddressBook\*) to proper module namespace
(MintyPHP\Module\AddressBook\Service\*), consistent with the
bookmarks module pattern.

Moved to module:
- AddressBookService → modules/addressbook/lib/Module/AddressBook/Service/
- AddressBookServicesFactory → modules/addressbook/lib/Module/AddressBook/Service/
- AddressBookServiceTest → modules/addressbook/tests/Module/AddressBook/Service/
- Pages, templates, CSS, JS all in module directory

All module PHP classes now live under MintyPHP\Module\<Name>\*,
enforced by ModuleStructureContractTest::testModuleClassesUseModuleNamespace.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 22:20:54 +01:00
parent 4871c6032e
commit c328067aa6
24 changed files with 373 additions and 114 deletions

View File

@@ -0,0 +1,80 @@
<?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.nav' 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.nav' => [
'url' => lurl('address-book'),
'activeSearch' => trim((string) ($query['search'] ?? '')),
'activeTenants' => appNormalizeStringList($query['tenants'] ?? ''),
'activeDepartments' => appNormalizePositiveIntList($query['departments'] ?? ''),
'activeRoles' => appNormalizePositiveIntList($query['roles'] ?? ''),
'activeCustomFilters' => self::normalizeCustomFilterQuery($query),
'peopleGroups' => is_array($session['available_departments_by_tenant'] ?? null)
? $session['available_departments_by_tenant']
: [],
],
];
}
/**
* Normalize address-book custom field query keys/values.
*
* @param array<string, mixed> $rawQuery
* @return array<string, string|list<int>>
*/
public static function normalizeCustomFilterQuery(array $rawQuery): array
{
$normalized = [];
foreach ($rawQuery as $rawKey => $rawValue) {
$key = strtolower(trim((string) $rawKey));
if ($key === '') {
continue;
}
if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) {
$value = trim((string) $rawValue);
if ($value !== '') {
$normalized[$key] = $value;
}
continue;
}
if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) {
$ids = appNormalizePositiveIntList($rawValue);
if ($ids) {
$normalized[$key] = $ids;
}
continue;
}
if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) {
$value = trim((string) $rawValue);
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $value);
if ($dt && $dt->format('Y-m-d') === $value) {
$normalized[$key] = $value;
}
}
}
ksort($normalized, SORT_STRING);
return $normalized;
}
}

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,40 @@
<?php
namespace MintyPHP\Module\AddressBook\Providers;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\SessionProvider;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Service\User\UserTenantContextService;
/**
* Populates session with department-by-tenant hierarchy data used by the
* address book aside panel.
*/
final class AddressBookSessionProvider implements SessionProvider
{
public function populate(array $user, AppContainer $container): void
{
$userId = (int) ($user['id'] ?? 0);
if ($userId <= 0) {
unset($_SESSION['available_departments_by_tenant']);
return;
}
$tenantContext = $container->get(UserTenantContextService::class);
$sessionStore = $container->get(SessionStoreInterface::class);
if (!$tenantContext instanceof UserTenantContextService || !$sessionStore instanceof SessionStoreInterface) {
return;
}
$sessionStore->set(
'available_departments_by_tenant',
$tenantContext->getAvailableDepartmentsByTenant($userId)
);
}
public function clear(): void
{
unset($_SESSION['available_departments_by_tenant']);
}
}