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

@@ -1,40 +0,0 @@
<?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

@@ -1,30 +0,0 @@
<?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']);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace MintyPHP\Module\AddressBook;
use MintyPHP\Service\Access\AuthorizationDecision;
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
use MintyPHP\Service\Access\PermissionService;
/**
* Authorization policy for address book abilities.
*
* Owns the 'addressbook.view' ability and delegates to the
* 'address_book.view' permission in the Core PermissionService.
*/
final class AddressBookAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_VIEW = 'addressbook.view';
public function __construct(
private readonly PermissionService $permissionService
) {
}
public function supports(string $ability): bool
{
return $ability === self::ABILITY_VIEW;
}
public function authorize(string $ability, array $context = []): AuthorizationDecision
{
$actorUserId = (int) ($context['actor_user_id'] ?? 0);
if ($actorUserId <= 0) {
return AuthorizationDecision::deny(403, 'forbidden');
}
if (!$this->permissionService->userHas($actorUserId, PermissionService::ADDRESS_BOOK_VIEW)) {
return AuthorizationDecision::deny(403, 'forbidden');
}
return AuthorizationDecision::allow();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace MintyPHP\Module\AddressBook;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Container\ContainerRegistrar;
use MintyPHP\Module\AddressBook\Service\AddressBookService;
use MintyPHP\Module\AddressBook\Service\AddressBookServicesFactory;
use MintyPHP\Service\CustomField\CustomFieldServicesFactory;
use MintyPHP\Service\Directory\DirectoryServicesFactory;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\User\UserServicesFactory;
final class AddressBookContainerRegistrar implements ContainerRegistrar
{
public function register(AppContainer $container): void
{
$container->set(AddressBookServicesFactory::class, static fn (AppContainer $c): AddressBookServicesFactory => new AddressBookServicesFactory(
$c->get(UserServicesFactory::class),
$c->get(DirectoryServicesFactory::class),
$c->get(CustomFieldServicesFactory::class),
$c->get(TenantScopeService::class)
));
$container->set(AddressBookService::class, static fn (AppContainer $c): AddressBookService => $c
->get(AddressBookServicesFactory::class)
->createAddressBookService());
}
}

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,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']);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace MintyPHP\Service\AddressBook;
namespace MintyPHP\Module\AddressBook\Service;
use MintyPHP\Repository\CustomField\TenantCustomFieldOptionRepository;
use MintyPHP\Service\Access\RoleService;
@@ -61,13 +61,11 @@ class AddressBookService
$roles = $this->roleService->listActive();
$customFieldFilterSpec = $this->customFieldValueService->extractAddressBookFilterSpec($query, $currentUserId);
$customFieldFilterSpec = $this->customFieldValueService->extractCustomFieldFilterSpec($query, $currentUserId);
$customFieldFilterDefinitions = $this->normalizeDefinitions(
$customFieldFilterSpec['definitions'] ?? []
$customFieldFilterSpec['definitions']
);
$customFieldFilterQuery = is_array($customFieldFilterSpec['query'] ?? null)
? $customFieldFilterSpec['query']
: [];
$customFieldFilterQuery = $customFieldFilterSpec['query'];
$customFieldDefinitionIds = [];
foreach ($customFieldFilterDefinitions as $definition) {
@@ -161,7 +159,7 @@ class AddressBookService
$tenants = $query['tenants'] ?? '';
$departments = $query['departments'] ?? '';
$roles = $query['roles'] ?? '';
$customFieldFilterSpec = $this->customFieldValueService->extractAddressBookFilterSpec($query, $currentUserId);
$customFieldFilterSpec = $this->customFieldValueService->extractCustomFieldFilterSpec($query, $currentUserId);
$result = $this->userAccountService->listPaged([
'limit' => $limit,

View File

@@ -1,6 +1,6 @@
<?php
namespace MintyPHP\Service\AddressBook;
namespace MintyPHP\Module\AddressBook\Service;
use MintyPHP\Service\CustomField\CustomFieldServicesFactory;
use MintyPHP\Service\Directory\DirectoryServicesFactory;

View File

@@ -3,13 +3,8 @@
/**
* Address Book module manifest.
*
* Contributes the "People" aside tab/panel, global search section,
* Contributes routes, sidebar slots, search entry, user-edit aside action,
* 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',
@@ -17,9 +12,18 @@ return [
'enabled_by_default' => false,
'load_order' => 10,
'routes' => [],
'routes' => [
// Keep canonical route stable without forcing a /index target to avoid Router redirect loops.
['path' => 'address-book', 'target' => 'address-book'],
['path' => 'address-book/data', 'target' => 'address-book/data'],
// Legacy aliases for existing bookmarks/links.
['path' => 'addressbook', 'target' => 'address-book'],
['path' => 'adressbook', 'target' => 'address-book'],
],
'public_paths' => [],
'container_registrars' => [],
'container_registrars' => [
\MintyPHP\Module\AddressBook\AddressBookContainerRegistrar::class,
],
'ui_slots' => [
'aside.tab_panel' => [
@@ -32,15 +36,47 @@ return [
'panel_template' => __DIR__ . '/templates/aside-people-panel.phtml',
'details_storage' => 'aside-people-tenant',
'details_open_active' => true,
'order' => 110,
],
],
'search.resource_item' => [
[
'key' => 'address-book',
'label' => 'Address book',
'base_url' => 'address-book',
'permission' => 'can_view_address_book',
'order' => 110,
],
],
'user.edit.aside_action' => [
[
'key' => 'address-book-view-user',
'type' => 'link',
'label' => 'View in address book',
'href' => 'address-book/view/{user_uuid}',
'permission' => 'can_view_address_book',
'order' => 110,
],
],
],
'authorization_policies' => [
\MintyPHP\Module\AddressBook\AddressBookAuthorizationPolicy::class,
],
'layout_capabilities' => [
'can_view_address_book' => 'addressbook.view',
],
'search_resources' => [
\MintyPHP\Module\AddressBook\Providers\AddressBookSearchProvider::class,
],
'asset_groups' => [],
'asset_groups' => [
'address-book' => [
'modules/addressbook/css/pages/address-book-view.css',
],
],
'scheduler_jobs' => [],
'layout_context_providers' => [
@@ -51,7 +87,14 @@ return [
\MintyPHP\Module\AddressBook\Providers\AddressBookSessionProvider::class,
],
'permissions' => [],
'permissions' => [
[
'key' => 'address_book.view',
'description' => 'Can view address book',
'active' => true,
'is_system' => true,
],
],
'migrations_path' => 'db/updates',
];

View File

@@ -1,13 +1,13 @@
<?php
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Service\Access\OperationsAuthorizationPolicy;
use MintyPHP\Service\AddressBook\AddressBookServicesFactory;
use MintyPHP\Module\AddressBook\AddressBookAuthorizationPolicy;
use MintyPHP\Module\AddressBook\Service\AddressBookServicesFactory;
use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
Guard::requireAbilityOrForbidden(OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW);
Guard::requireAbilityOrForbidden(AddressBookAuthorizationPolicy::ABILITY_VIEW);
gridRequireGetRequest();
$query = requestInput()->queryAll();

View File

@@ -2,13 +2,13 @@
use MintyPHP\Buffer;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Service\AddressBook\AddressBookServicesFactory;
use MintyPHP\Module\AddressBook\Service\AddressBookServicesFactory;
use MintyPHP\Session;
use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
Guard::requireAbilityOrForbidden(\MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW);
Guard::requireAbilityOrForbidden(\MintyPHP\Module\AddressBook\AddressBookAuthorizationPolicy::ABILITY_VIEW);
$filterSchema = require __DIR__ . '/filter-schema.php';
$query = requestInput()->queryAll();

View File

@@ -239,4 +239,4 @@ $pageConfig = [
?>
<script src="<?php e(assetVersion('vendor/gridjs/gridjs.umd.js')); ?>"></script>
<script type="application/json" id="page-config-address-book-index"><?php gridJsonForJs($pageConfig); ?></script>
<script type="module" src="<?php e(assetVersion('js/pages/address-book-index.js')); ?>"></script>
<script type="module" src="<?php e(assetVersion('modules/addressbook/js/pages/address-book-index.js')); ?>"></script>

View File

@@ -8,7 +8,7 @@ use MintyPHP\Support\Guard;
$session = app(SessionStoreInterface::class)->all();
Guard::requireLogin();
Guard::requireAbilityOrForbidden(\MintyPHP\Service\Access\OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW);
Guard::requireAbilityOrForbidden(\MintyPHP\Module\AddressBook\AddressBookAuthorizationPolicy::ABILITY_VIEW);
$currentUserId = (int) ($session['user']['id'] ?? 0);
$uuid = trim((string) ($id ?? ''));
@@ -16,7 +16,7 @@ if ($uuid === '') {
Router::redirect('address-book');
}
$addressBookService = app(\MintyPHP\Service\AddressBook\AddressBookService::class);
$addressBookService = app(\MintyPHP\Module\AddressBook\Service\AddressBookService::class);
$viewContext = $addressBookService->buildViewContext($currentUserId, $uuid);
$status = (string) ($viewContext['status'] ?? '');
if ($status === 'not_found') {

View File

@@ -91,7 +91,7 @@ if ($profileDescription !== '') {
</div>
<div class="app-profile-body-container">
<div class="app-profile-body">
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="address-book-profile">
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="address-book-profile">
<div class="app-tabs-nav">
<?php if ($hasContact): ?>
<button type="button" data-tab="contact" data-tab-default><small><?php e(t('Contact')); ?></small></button>

View File

@@ -8,7 +8,7 @@
*
* Available in scope via include: $layoutNav, $layoutAuth, $viewAuth
*/
$addressBook = is_array($layoutNav['addressBook'] ?? null) ? $layoutNav['addressBook'] : [];
$addressBook = is_array($layoutNav['addressbook.nav'] ?? null) ? $layoutNav['addressbook.nav'] : [];
$addressBookUrl = trim((string) ($addressBook['url'] ?? lurl('address-book')));
$activeAddressSearch = trim((string) ($addressBook['activeSearch'] ?? ''));
$activeAddressTenants = is_array($addressBook['activeTenants'] ?? null) ? $addressBook['activeTenants'] : [];

View File

@@ -1,9 +1,9 @@
<?php
namespace MintyPHP\Tests\Service\AddressBook;
namespace MintyPHP\Tests\Module\AddressBook\Service;
use MintyPHP\Service\Access\RoleService;
use MintyPHP\Service\AddressBook\AddressBookService;
use MintyPHP\Module\AddressBook\Service\AddressBookService;
use MintyPHP\Service\CustomField\UserCustomFieldValueService;
use MintyPHP\Service\Org\DepartmentService;
use MintyPHP\Service\Tenant\TenantScopeService;
@@ -59,7 +59,7 @@ class AddressBookServiceTest extends TestCase
$customFieldGateway
->expects($this->once())
->method('extractAddressBookFilterSpec')
->method('extractCustomFieldFilterSpec')
->with([], 7)
->willReturn([
'definitions' => [],

View File

@@ -1,9 +1,9 @@
import { initMultiSelect } from '../components/app-multiselect-init.js';
import { initMultiSelectCascade } from '../components/app-multiselect-cascade.js';
import { initStandardListPage } from '../core/app-grid-factory.js';
import { readPageConfig } from '../core/app-page-config.js';
import { warnOnce } from '../core/app-dom.js';
import { getAppBase, escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText } from './app-list-utils.js';
import { initMultiSelect } from '/js/components/app-multiselect-init.js';
import { initMultiSelectCascade } from '/js/components/app-multiselect-cascade.js';
import { initStandardListPage } from '/js/core/app-grid-factory.js';
import { readPageConfig } from '/js/core/app-page-config.js';
import { warnOnce } from '/js/core/app-dom.js';
import { getAppBase, escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText } from '/js/pages/app-list-utils.js';
const config = readPageConfig('address-book-index');
if (config) {

View File

@@ -14,9 +14,7 @@ use FilesystemIterator;
* Intentionally allowed in Core (not flagged):
* - PermissionService::ADDRESS_BOOK_VIEW (general people-visibility gate)
* - OperationsAuthorizationPolicy / UiCapabilityMap / UserAuthorizationPolicy (authorization)
* - AddressBookService / AddressBookServicesFactory (business logic, V1 shared-db)
* - pages/address-book/* (page files, not hardcoded integrations)
* - config/assets.php (asset pipeline not yet modular)
* - modules/addressbook/* (module-owned runtime, pages/assets/providers)
* - i18n translation files
* - Module directory (lib/Module/, modules/)
* - Test files
@@ -90,10 +88,7 @@ final class NoAddressBookHardcodingTest extends TestCase
}
/**
* Aside panel template must not have hardcoded People panel.
*
* Note: The search panel legitimately contains data-search-key="address-book"
* for the global search results — that is NOT a hardcoded People panel.
* Aside panel template must not have hardcoded People panel/search entries.
*/
public function testAsideHasNoHardcodedPeoplePanel(): void
{
@@ -116,6 +111,20 @@ final class NoAddressBookHardcodingTest extends TestCase
$content,
'Aside still defines $peopleGroups as a standalone variable'
);
self::assertStringNotContainsString(
'data-search-key="address-book"',
$content,
'Aside still has static address-book search item markup'
);
}
public function testCoreNoLongerContainsAddressBookServiceDirectory(): void
{
$coreServiceDir = dirname(__DIR__, 2) . '/lib/Service/AddressBook';
self::assertDirectoryDoesNotExist(
$coreServiceDir,
'Addressbook service classes should live in modules/addressbook/lib, not in Core lib/Service'
);
}
/**

View File

@@ -0,0 +1,87 @@
<?php
namespace MintyPHP\Tests\Unit\Module;
use MintyPHP\Module\AddressBook\AddressBookAuthorizationPolicy;
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Tests\Service\Access\AuthorizationPolicyTestSupport;
use PHPUnit\Framework\TestCase;
/**
* @covers \MintyPHP\Module\AddressBook\AddressBookAuthorizationPolicy
*/
final class AddressBookAuthorizationPolicyTest extends TestCase
{
use AuthorizationPolicyTestSupport;
public function testImplementsInterface(): void
{
$policy = new AddressBookAuthorizationPolicy($this->createMock(PermissionService::class));
self::assertInstanceOf(AuthorizationPolicyInterface::class, $policy);
}
public function testSupportsOwnAbility(): void
{
$policy = new AddressBookAuthorizationPolicy($this->createMock(PermissionService::class));
self::assertTrue($policy->supports(AddressBookAuthorizationPolicy::ABILITY_VIEW));
}
public function testDoesNotSupportOtherAbilities(): void
{
$policy = new AddressBookAuthorizationPolicy($this->createMock(PermissionService::class));
self::assertFalse($policy->supports('ops.admin.jobs.view'));
self::assertFalse($policy->supports(''));
self::assertFalse($policy->supports('ops.address_book.view')); // old ability string
}
public function testAllowedWithPermission(): void
{
$permissionService = $this->permissionGatewayAllowing([
5 => [PermissionService::ADDRESS_BOOK_VIEW],
]);
$policy = new AddressBookAuthorizationPolicy($permissionService);
$decision = $policy->authorize(AddressBookAuthorizationPolicy::ABILITY_VIEW, [
'actor_user_id' => 5,
]);
self::assertTrue($decision->isAllowed());
}
public function testDeniedWithoutPermission(): void
{
$permissionService = $this->permissionGatewayAllowing([5 => []]);
$policy = new AddressBookAuthorizationPolicy($permissionService);
$decision = $policy->authorize(AddressBookAuthorizationPolicy::ABILITY_VIEW, [
'actor_user_id' => 5,
]);
$this->assertForbiddenDecision($decision);
}
public function testDeniedWithZeroActorId(): void
{
$permissionService = $this->createMock(PermissionService::class);
$permissionService->expects($this->never())->method('userHas');
$policy = new AddressBookAuthorizationPolicy($permissionService);
$decision = $policy->authorize(AddressBookAuthorizationPolicy::ABILITY_VIEW, [
'actor_user_id' => 0,
]);
$this->assertForbiddenDecision($decision);
}
public function testDeniedWithMissingActorId(): void
{
$permissionService = $this->createMock(PermissionService::class);
$permissionService->expects($this->never())->method('userHas');
$policy = new AddressBookAuthorizationPolicy($permissionService);
$decision = $policy->authorize(AddressBookAuthorizationPolicy::ABILITY_VIEW, []);
$this->assertForbiddenDecision($decision);
}
}

1
web/modules/addressbook Symbolic link
View File

@@ -0,0 +1 @@
/var/www/modules/addressbook/web