Move the entire audit subsystem (system audit, API audit, import audit, user lifecycle audit, frontend telemetry) from core into modules/audit/. Core decoupling via interface-based injection: - AuditRecorderInterface replaces SystemAuditService in 10+ core services - UserLifecycleAuditInterface / ImportAuditInterface for specialized flows - NullAuditRecorder fallback when audit module is disabled - ApiBootstrap/ApiResponse use null-safe callable resolvers Module structure (modules/audit/): - Manifest with routes, permissions, scheduler jobs, authorization policy - 9 services, 8 repositories, 6 domain enums, 4 job handlers - 33 page files, 4 JS files, 8 test files, migration scripts, i18n Core cleanup: - OperationsAuthorizationPolicy, UiCapabilityMap, PermissionService surgically cleaned of audit-specific constants - Sidebar template cleared of hardcoded audit navigation - AuditModuleIsolationContractTest ensures no future core→module coupling All quality gates pass: 1346 tests (19,276 assertions), PHPStan level 5 clean, architecture contracts verified. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
264 lines
11 KiB
PHP
264 lines
11 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Service\Tenant;
|
|
|
|
use MintyPHP\Domain\Taxonomy\TenantStatus;
|
|
use MintyPHP\Repository\Org\DepartmentRepositoryInterface;
|
|
use MintyPHP\Repository\Tenant\TenantRepositoryInterface;
|
|
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
|
use MintyPHP\Service\Directory\DirectorySettingsGateway;
|
|
|
|
class TenantService
|
|
{
|
|
public function __construct(
|
|
private readonly TenantRepositoryInterface $tenantRepository,
|
|
private readonly DepartmentRepositoryInterface $departmentRepository,
|
|
private readonly DirectorySettingsGateway $settingsGateway,
|
|
private readonly AuditRecorderInterface $systemAuditService
|
|
) {
|
|
}
|
|
|
|
public function list(): array
|
|
{
|
|
return $this->tenantRepository->list();
|
|
}
|
|
|
|
public function listPaged(array $options): array
|
|
{
|
|
return $this->tenantRepository->listPaged($options);
|
|
}
|
|
|
|
public function findByUuid(string $uuid): ?array
|
|
{
|
|
return $this->tenantRepository->findByUuid($uuid);
|
|
}
|
|
|
|
public function findById(int $id): ?array
|
|
{
|
|
return $this->tenantRepository->find($id);
|
|
}
|
|
|
|
public function createFromAdmin(array $input, int $currentUserId = 0): array
|
|
{
|
|
$form = $this->sanitizeBase($input);
|
|
$form['status'] = $this->normalizeStatus($form['status'] ?? '');
|
|
$errors = $this->validateBase($form);
|
|
|
|
if ($errors) {
|
|
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
|
}
|
|
|
|
// Always UTC — consistency across server timezone changes.
|
|
$statusChangedAt = gmdate('Y-m-d H:i:s');
|
|
$createdId = $this->tenantRepository->create([
|
|
'description' => $form['description'],
|
|
'address' => $form['address'],
|
|
'postal_code' => $form['postal_code'],
|
|
'city' => $form['city'],
|
|
'country' => $form['country'],
|
|
'region' => $form['region'],
|
|
'vat_id' => $form['vat_id'],
|
|
'tax_number' => $form['tax_number'],
|
|
'phone' => $form['phone'],
|
|
'fax' => $form['fax'],
|
|
'email' => $form['email'],
|
|
'support_email' => $form['support_email'],
|
|
'support_phone' => $form['support_phone'],
|
|
'billing_email' => $form['billing_email'],
|
|
'website' => $form['website'],
|
|
'privacy_url' => $form['privacy_url'],
|
|
'imprint_url' => $form['imprint_url'],
|
|
// null means "inherit from global settings" for color, theme, and theme-toggle policy.
|
|
'primary_color' => $form['primary_color_use_default']
|
|
? null
|
|
: ($form['primary_color'] !== '' ? $form['primary_color'] : null),
|
|
'default_theme' => $form['default_theme'] !== '' ? $form['default_theme'] : null,
|
|
'allow_user_theme' => $form['allow_user_theme_mode'] === '' ? null : (int) $form['allow_user_theme_mode'],
|
|
'status' => $form['status'],
|
|
'status_changed_at' => $statusChangedAt,
|
|
'status_changed_by' => $currentUserId > 0 ? $currentUserId : null,
|
|
'created_by' => $currentUserId > 0 ? $currentUserId : null,
|
|
]);
|
|
|
|
if (!$createdId) {
|
|
return ['ok' => false, 'errors' => [t('Tenant can not be created')], 'form' => $form];
|
|
}
|
|
|
|
$createdTenant = $this->tenantRepository->find((int) $createdId);
|
|
$uuid = $createdTenant['uuid'] ?? null;
|
|
if (!empty($input['is_default'])) {
|
|
$this->settingsGateway->setDefaultTenantId((int) $createdId);
|
|
}
|
|
|
|
$this->systemAuditService->record('admin.tenants.create', 'success', [
|
|
'actor_user_id' => $currentUserId > 0 ? $currentUserId : null,
|
|
'target_type' => 'tenant',
|
|
'target_id' => (int) $createdId,
|
|
'target_uuid' => is_string($uuid) ? $uuid : '',
|
|
'metadata' => ['status' => $form['status']],
|
|
]);
|
|
|
|
return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId];
|
|
}
|
|
|
|
public function updateFromAdmin(int $tenantId, array $input, int $currentUserId = 0): array
|
|
{
|
|
$form = $this->sanitizeBase($input);
|
|
$form['status'] = $this->normalizeStatus($form['status'] ?? '');
|
|
$errors = $this->validateBase($form);
|
|
|
|
if ($errors) {
|
|
return ['ok' => false, 'errors' => $errors, 'form' => $form];
|
|
}
|
|
|
|
$existing = $this->tenantRepository->find($tenantId);
|
|
$statusChanged = false;
|
|
if ($existing && isset($existing['status'])) {
|
|
$statusChanged = (string) $existing['status'] !== $form['status'];
|
|
}
|
|
|
|
$updateData = [
|
|
'description' => $form['description'],
|
|
'address' => $form['address'],
|
|
'postal_code' => $form['postal_code'],
|
|
'city' => $form['city'],
|
|
'country' => $form['country'],
|
|
'region' => $form['region'],
|
|
'vat_id' => $form['vat_id'],
|
|
'tax_number' => $form['tax_number'],
|
|
'phone' => $form['phone'],
|
|
'fax' => $form['fax'],
|
|
'email' => $form['email'],
|
|
'support_email' => $form['support_email'],
|
|
'support_phone' => $form['support_phone'],
|
|
'billing_email' => $form['billing_email'],
|
|
'website' => $form['website'],
|
|
'privacy_url' => $form['privacy_url'],
|
|
'imprint_url' => $form['imprint_url'],
|
|
'primary_color' => $form['primary_color_use_default']
|
|
? null
|
|
: ($form['primary_color'] !== '' ? $form['primary_color'] : null),
|
|
'default_theme' => $form['default_theme'] !== '' ? $form['default_theme'] : null,
|
|
'allow_user_theme' => $form['allow_user_theme_mode'] === '' ? null : (int) $form['allow_user_theme_mode'],
|
|
'status' => $form['status'],
|
|
'modified_by' => $currentUserId > 0 ? $currentUserId : null,
|
|
];
|
|
if ($statusChanged) {
|
|
$updateData['status_changed_at'] = gmdate('Y-m-d H:i:s');
|
|
$updateData['status_changed_by'] = $currentUserId > 0 ? $currentUserId : null;
|
|
}
|
|
|
|
$updated = $this->tenantRepository->update($tenantId, $updateData);
|
|
|
|
if (!$updated) {
|
|
return ['ok' => false, 'errors' => [t('Tenant can not be updated')], 'form' => $form];
|
|
}
|
|
|
|
$this->systemAuditService->record('admin.tenants.update', 'success', [
|
|
'actor_user_id' => $currentUserId > 0 ? $currentUserId : null,
|
|
'target_type' => 'tenant',
|
|
'target_id' => $tenantId,
|
|
'target_uuid' => (string) ($existing['uuid'] ?? ''),
|
|
'before' => [
|
|
'status' => $existing['status'] ?? null,
|
|
],
|
|
'after' => [
|
|
'status' => $form['status'],
|
|
],
|
|
]);
|
|
|
|
return ['ok' => true, 'form' => $form];
|
|
}
|
|
|
|
public function deleteByUuid(string $uuid): array
|
|
{
|
|
$uuid = trim($uuid);
|
|
if ($uuid === '') {
|
|
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
|
}
|
|
|
|
$tenant = $this->tenantRepository->findByUuid($uuid);
|
|
if (!$tenant || !isset($tenant['id'])) {
|
|
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
|
|
}
|
|
|
|
$tenantId = (int) $tenant['id'];
|
|
// Block deletion if departments exist — orphaned departments would break user assignments.
|
|
if ($this->departmentRepository->countByTenantId($tenantId) > 0) {
|
|
return ['ok' => false, 'status' => 409, 'error' => 'tenant_has_departments'];
|
|
}
|
|
|
|
$deleted = $this->tenantRepository->delete($tenantId);
|
|
if (!$deleted) {
|
|
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
|
|
}
|
|
|
|
$this->systemAuditService->record('admin.tenants.delete', 'success', [
|
|
'target_type' => 'tenant',
|
|
'target_id' => $tenantId,
|
|
'target_uuid' => (string) ($tenant['uuid'] ?? ''),
|
|
]);
|
|
|
|
return ['ok' => true, 'tenant' => $tenant];
|
|
}
|
|
|
|
private function sanitizeBase(array $input): array
|
|
{
|
|
return [
|
|
'description' => trim((string) ($input['description'] ?? '')),
|
|
'address' => trim((string) ($input['address'] ?? '')),
|
|
'postal_code' => trim((string) ($input['postal_code'] ?? '')),
|
|
'city' => trim((string) ($input['city'] ?? '')),
|
|
'country' => trim((string) ($input['country'] ?? '')),
|
|
'region' => trim((string) ($input['region'] ?? '')),
|
|
'vat_id' => trim((string) ($input['vat_id'] ?? '')),
|
|
'tax_number' => trim((string) ($input['tax_number'] ?? '')),
|
|
'phone' => trim((string) ($input['phone'] ?? '')),
|
|
'fax' => trim((string) ($input['fax'] ?? '')),
|
|
'email' => trim((string) ($input['email'] ?? '')),
|
|
'support_email' => trim((string) ($input['support_email'] ?? '')),
|
|
'support_phone' => trim((string) ($input['support_phone'] ?? '')),
|
|
'billing_email' => trim((string) ($input['billing_email'] ?? '')),
|
|
'website' => trim((string) ($input['website'] ?? '')),
|
|
'privacy_url' => trim((string) ($input['privacy_url'] ?? '')),
|
|
'imprint_url' => trim((string) ($input['imprint_url'] ?? '')),
|
|
'primary_color' => trim((string) ($input['primary_color'] ?? '')),
|
|
'primary_color_use_default' => !empty($input['primary_color_use_default']),
|
|
'default_theme' => strtolower(trim((string) ($input['default_theme'] ?? ''))),
|
|
'allow_user_theme_mode' => trim((string) ($input['allow_user_theme_mode'] ?? '')),
|
|
'status' => trim((string) ($input['status'] ?? '')),
|
|
];
|
|
}
|
|
|
|
private function validateBase(array $form): array
|
|
{
|
|
$errors = [];
|
|
if ($form['description'] === '') {
|
|
$errors[] = t('Description cannot be empty');
|
|
}
|
|
if (TenantStatus::tryNormalize((string) $form['status']) === null) {
|
|
$errors[] = t('Status is invalid');
|
|
}
|
|
if (
|
|
!$form['primary_color_use_default']
|
|
&& $form['primary_color'] !== ''
|
|
&& !preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $form['primary_color'])
|
|
) {
|
|
$errors[] = t('Primary color is invalid');
|
|
}
|
|
if ($form['default_theme'] !== '' && !$this->settingsGateway->isAllowedTheme((string) $form['default_theme'])) {
|
|
$errors[] = t('Default theme is invalid');
|
|
}
|
|
if (!in_array($form['allow_user_theme_mode'], ['', '0', '1'], true)) {
|
|
$errors[] = t('User theme policy is invalid');
|
|
}
|
|
return $errors;
|
|
}
|
|
|
|
private function normalizeStatus(string $status): string
|
|
{
|
|
return TenantStatus::normalizeOr($status, TenantStatus::Active)->value;
|
|
}
|
|
|
|
}
|