Files
breadcrumb-the-shire/core/Service/Tenant/TenantService.php

257 lines
10 KiB
PHP
Raw Normal View History

2026-02-04 23:31:53 +01:00
<?php
2026-02-11 19:28:12 +01:00
namespace MintyPHP\Service\Tenant;
2026-02-04 23:31:53 +01:00
2026-03-04 15:56:58 +01:00
use MintyPHP\Domain\Taxonomy\TenantStatus;
2026-03-05 08:26:51 +01:00
use MintyPHP\Repository\Org\DepartmentRepositoryInterface;
use MintyPHP\Repository\Tenant\TenantRepositoryInterface;
use MintyPHP\Service\Audit\AuditRecorderInterface;
2026-02-23 12:58:19 +01:00
use MintyPHP\Service\Directory\DirectorySettingsGateway;
2026-02-04 23:31:53 +01:00
class TenantService
{
2026-02-23 12:58:19 +01:00
public function __construct(
2026-03-05 08:26:51 +01:00
private readonly TenantRepositoryInterface $tenantRepository,
private readonly DepartmentRepositoryInterface $departmentRepository,
2026-03-04 15:56:58 +01:00
private readonly DirectorySettingsGateway $settingsGateway,
private readonly AuditRecorderInterface $systemAuditService
2026-02-23 12:58:19 +01:00
) {
}
public function list(): array
2026-02-04 23:31:53 +01:00
{
2026-03-04 15:56:58 +01:00
return $this->tenantRepository->list();
2026-02-04 23:31:53 +01:00
}
2026-02-23 12:58:19 +01:00
public function listPaged(array $options): array
2026-02-04 23:31:53 +01:00
{
2026-03-04 15:56:58 +01:00
return $this->tenantRepository->listPaged($options);
2026-02-04 23:31:53 +01:00
}
2026-02-23 12:58:19 +01:00
public function findByUuid(string $uuid): ?array
2026-02-04 23:31:53 +01:00
{
2026-03-04 15:56:58 +01:00
return $this->tenantRepository->findByUuid($uuid);
2026-02-04 23:31:53 +01:00
}
2026-02-23 12:58:19 +01:00
public function findById(int $id): ?array
2026-02-04 23:31:53 +01:00
{
2026-03-04 15:56:58 +01:00
return $this->tenantRepository->find($id);
2026-02-04 23:31:53 +01:00
}
2026-02-23 12:58:19 +01:00
public function createFromAdmin(array $input, int $currentUserId = 0): array
2026-02-04 23:31:53 +01:00
{
2026-02-23 12:58:19 +01:00
$form = $this->sanitizeBase($input);
$form['status'] = $this->normalizeStatus($form['status'] ?? '');
$errors = $this->validateBase($form);
2026-02-04 23:31:53 +01:00
if ($errors) {
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
2026-03-06 00:44:52 +01:00
// Always UTC — consistency across server timezone changes.
2026-02-04 23:31:53 +01:00
$statusChangedAt = gmdate('Y-m-d H:i:s');
2026-03-04 15:56:58 +01:00
$createdId = $this->tenantRepository->create([
2026-02-04 23:31:53 +01:00
'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'],
refactor(tenants): visibility form requires explicit appearance values Simplifies the tenant Visibility tab to match the tenant-only appearance model established by the previous commits: - primary_color: now required. Removed the "No brand color / Use system default (no brand accent)" toggle — every tenant carries a concrete primary color. Legacy NULL rows render the neutral app default (#2fa4a4) in the color picker and are converted to that explicit hex on save. - default_theme: the select no longer offers a "Use system default" empty option. Legacy NULL rows resolve to 'light' at render time; saves always persist a valid theme via SettingsAppGateway::normalizeTheme(). - allow_user_theme: replaced the tri-state "Use system default (allowed) / Force allow / Force disallow" select with a single boolean switch ("Users may choose their own theme"). Legacy NULL rows load as checked. Saves persist 0/1 explicitly. TenantService: sanitize no longer reads primary_color_use_default or allow_user_theme_mode; it validates primary_color as a required hex and treats allow_user_theme as a plain boolean. Both create and update paths write concrete values only — no more NULL writes for these three fields. DirectorySettingsGateway gains a normalizeTheme() delegate so TenantService can route through the same gateway it uses for isAllowedTheme(). Removed now-unused app-color-default-toggle JS component + its runtime registration + its architecture-test entry. i18n cleanup: "No brand color", "Use system default (no brand accent)", "When enabled the tenant renders without a brand accent color.", "Use system default (light)", "Use system default (allowed)", "Force allow user theme", "Force disallow user theme", "User theme policy is invalid" all removed. New copy: "Users may choose their own theme" + helper text, plus a tightened tab blockquote. Tests: TenantServiceTest validInput() updated to send concrete values; settingsGateway mock gets normalizeTheme() + isAllowedTheme() defaults. All 1985 tests pass; PHPStan level 5 clean; QG-006 clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:10:44 +02:00
// Appearance is tenant-scoped with explicit, required values. Every
// tenant has a primary color, a default theme, and an explicit
// allow-user-theme flag — there is no global fallback to inherit.
'primary_color' => strtolower($form['primary_color']),
'default_theme' => $this->settingsGateway->normalizeTheme($form['default_theme']),
'allow_user_theme' => $form['allow_user_theme'] ? 1 : 0,
2026-02-04 23:31:53 +01:00
'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];
}
2026-03-04 15:56:58 +01:00
$createdTenant = $this->tenantRepository->find((int) $createdId);
2026-02-04 23:31:53 +01:00
$uuid = $createdTenant['uuid'] ?? null;
if (!empty($input['is_default'])) {
2026-03-04 15:56:58 +01:00
$this->settingsGateway->setDefaultTenantId((int) $createdId);
2026-02-04 23:31:53 +01:00
}
2026-03-04 15:56:58 +01:00
$this->systemAuditService->record('admin.tenants.create', 'success', [
2026-03-04 15:56:58 +01:00
'actor_user_id' => $currentUserId > 0 ? $currentUserId : null,
'target_type' => 'tenant',
'target_id' => (int) $createdId,
'target_uuid' => is_string($uuid) ? $uuid : '',
'metadata' => ['status' => $form['status']],
]);
2026-02-04 23:31:53 +01:00
return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId];
}
2026-02-23 12:58:19 +01:00
public function updateFromAdmin(int $tenantId, array $input, int $currentUserId = 0): array
2026-02-04 23:31:53 +01:00
{
2026-02-23 12:58:19 +01:00
$form = $this->sanitizeBase($input);
$form['status'] = $this->normalizeStatus($form['status'] ?? '');
$errors = $this->validateBase($form);
2026-02-04 23:31:53 +01:00
if ($errors) {
return ['ok' => false, 'errors' => $errors, 'form' => $form];
}
2026-03-04 15:56:58 +01:00
$existing = $this->tenantRepository->find($tenantId);
2026-02-04 23:31:53 +01:00
$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'],
refactor(tenants): visibility form requires explicit appearance values Simplifies the tenant Visibility tab to match the tenant-only appearance model established by the previous commits: - primary_color: now required. Removed the "No brand color / Use system default (no brand accent)" toggle — every tenant carries a concrete primary color. Legacy NULL rows render the neutral app default (#2fa4a4) in the color picker and are converted to that explicit hex on save. - default_theme: the select no longer offers a "Use system default" empty option. Legacy NULL rows resolve to 'light' at render time; saves always persist a valid theme via SettingsAppGateway::normalizeTheme(). - allow_user_theme: replaced the tri-state "Use system default (allowed) / Force allow / Force disallow" select with a single boolean switch ("Users may choose their own theme"). Legacy NULL rows load as checked. Saves persist 0/1 explicitly. TenantService: sanitize no longer reads primary_color_use_default or allow_user_theme_mode; it validates primary_color as a required hex and treats allow_user_theme as a plain boolean. Both create and update paths write concrete values only — no more NULL writes for these three fields. DirectorySettingsGateway gains a normalizeTheme() delegate so TenantService can route through the same gateway it uses for isAllowedTheme(). Removed now-unused app-color-default-toggle JS component + its runtime registration + its architecture-test entry. i18n cleanup: "No brand color", "Use system default (no brand accent)", "When enabled the tenant renders without a brand accent color.", "Use system default (light)", "Use system default (allowed)", "Force allow user theme", "Force disallow user theme", "User theme policy is invalid" all removed. New copy: "Users may choose their own theme" + helper text, plus a tightened tab blockquote. Tests: TenantServiceTest validInput() updated to send concrete values; settingsGateway mock gets normalizeTheme() + isAllowedTheme() defaults. All 1985 tests pass; PHPStan level 5 clean; QG-006 clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:10:44 +02:00
'primary_color' => strtolower($form['primary_color']),
'default_theme' => $this->settingsGateway->normalizeTheme($form['default_theme']),
'allow_user_theme' => $form['allow_user_theme'] ? 1 : 0,
2026-02-04 23:31:53 +01:00
'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;
}
2026-03-04 15:56:58 +01:00
$updated = $this->tenantRepository->update($tenantId, $updateData);
2026-02-04 23:31:53 +01:00
if (!$updated) {
return ['ok' => false, 'errors' => [t('Tenant can not be updated')], 'form' => $form];
}
$this->systemAuditService->record('admin.tenants.update', 'success', [
2026-03-04 15:56:58 +01:00
'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'],
],
]);
2026-02-04 23:31:53 +01:00
return ['ok' => true, 'form' => $form];
}
2026-02-23 12:58:19 +01:00
public function deleteByUuid(string $uuid): array
2026-02-04 23:31:53 +01:00
{
$uuid = trim($uuid);
if ($uuid === '') {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
2026-03-04 15:56:58 +01:00
$tenant = $this->tenantRepository->findByUuid($uuid);
2026-02-04 23:31:53 +01:00
if (!$tenant || !isset($tenant['id'])) {
return ['ok' => false, 'status' => 404, 'error' => 'not_found'];
}
$tenantId = (int) $tenant['id'];
2026-03-06 00:44:52 +01:00
// Block deletion if departments exist — orphaned departments would break user assignments.
2026-03-04 15:56:58 +01:00
if ($this->departmentRepository->countByTenantId($tenantId) > 0) {
return ['ok' => false, 'status' => 409, 'error' => 'tenant_has_departments'];
}
2026-03-04 15:56:58 +01:00
$deleted = $this->tenantRepository->delete($tenantId);
2026-02-04 23:31:53 +01:00
if (!$deleted) {
return ['ok' => false, 'status' => 500, 'error' => 'delete_failed'];
}
$this->systemAuditService->record('admin.tenants.delete', 'success', [
2026-03-04 15:56:58 +01:00
'target_type' => 'tenant',
'target_id' => $tenantId,
'target_uuid' => (string) ($tenant['uuid'] ?? ''),
]);
2026-02-04 23:31:53 +01:00
return ['ok' => true, 'tenant' => $tenant];
}
2026-02-23 12:58:19 +01:00
private function sanitizeBase(array $input): array
2026-02-04 23:31:53 +01:00
{
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'] ?? '')),
'default_theme' => strtolower(trim((string) ($input['default_theme'] ?? ''))),
refactor(tenants): visibility form requires explicit appearance values Simplifies the tenant Visibility tab to match the tenant-only appearance model established by the previous commits: - primary_color: now required. Removed the "No brand color / Use system default (no brand accent)" toggle — every tenant carries a concrete primary color. Legacy NULL rows render the neutral app default (#2fa4a4) in the color picker and are converted to that explicit hex on save. - default_theme: the select no longer offers a "Use system default" empty option. Legacy NULL rows resolve to 'light' at render time; saves always persist a valid theme via SettingsAppGateway::normalizeTheme(). - allow_user_theme: replaced the tri-state "Use system default (allowed) / Force allow / Force disallow" select with a single boolean switch ("Users may choose their own theme"). Legacy NULL rows load as checked. Saves persist 0/1 explicitly. TenantService: sanitize no longer reads primary_color_use_default or allow_user_theme_mode; it validates primary_color as a required hex and treats allow_user_theme as a plain boolean. Both create and update paths write concrete values only — no more NULL writes for these three fields. DirectorySettingsGateway gains a normalizeTheme() delegate so TenantService can route through the same gateway it uses for isAllowedTheme(). Removed now-unused app-color-default-toggle JS component + its runtime registration + its architecture-test entry. i18n cleanup: "No brand color", "Use system default (no brand accent)", "When enabled the tenant renders without a brand accent color.", "Use system default (light)", "Use system default (allowed)", "Force allow user theme", "Force disallow user theme", "User theme policy is invalid" all removed. New copy: "Users may choose their own theme" + helper text, plus a tightened tab blockquote. Tests: TenantServiceTest validInput() updated to send concrete values; settingsGateway mock gets normalizeTheme() + isAllowedTheme() defaults. All 1985 tests pass; PHPStan level 5 clean; QG-006 clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:10:44 +02:00
'allow_user_theme' => isset($input['allow_user_theme']) ? (bool) $input['allow_user_theme'] : true,
2026-02-04 23:31:53 +01:00
'status' => trim((string) ($input['status'] ?? '')),
];
}
2026-02-23 12:58:19 +01:00
private function validateBase(array $form): array
2026-02-04 23:31:53 +01:00
{
$errors = [];
if ($form['description'] === '') {
$errors[] = t('Description cannot be empty');
}
2026-03-04 15:56:58 +01:00
if (TenantStatus::tryNormalize((string) $form['status']) === null) {
2026-02-04 23:31:53 +01:00
$errors[] = t('Status is invalid');
}
if (
refactor(tenants): visibility form requires explicit appearance values Simplifies the tenant Visibility tab to match the tenant-only appearance model established by the previous commits: - primary_color: now required. Removed the "No brand color / Use system default (no brand accent)" toggle — every tenant carries a concrete primary color. Legacy NULL rows render the neutral app default (#2fa4a4) in the color picker and are converted to that explicit hex on save. - default_theme: the select no longer offers a "Use system default" empty option. Legacy NULL rows resolve to 'light' at render time; saves always persist a valid theme via SettingsAppGateway::normalizeTheme(). - allow_user_theme: replaced the tri-state "Use system default (allowed) / Force allow / Force disallow" select with a single boolean switch ("Users may choose their own theme"). Legacy NULL rows load as checked. Saves persist 0/1 explicitly. TenantService: sanitize no longer reads primary_color_use_default or allow_user_theme_mode; it validates primary_color as a required hex and treats allow_user_theme as a plain boolean. Both create and update paths write concrete values only — no more NULL writes for these three fields. DirectorySettingsGateway gains a normalizeTheme() delegate so TenantService can route through the same gateway it uses for isAllowedTheme(). Removed now-unused app-color-default-toggle JS component + its runtime registration + its architecture-test entry. i18n cleanup: "No brand color", "Use system default (no brand accent)", "When enabled the tenant renders without a brand accent color.", "Use system default (light)", "Use system default (allowed)", "Force allow user theme", "Force disallow user theme", "User theme policy is invalid" all removed. New copy: "Users may choose their own theme" + helper text, plus a tightened tab blockquote. Tests: TenantServiceTest validInput() updated to send concrete values; settingsGateway mock gets normalizeTheme() + isAllowedTheme() defaults. All 1985 tests pass; PHPStan level 5 clean; QG-006 clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:10:44 +02:00
$form['primary_color'] === ''
|| !preg_match('/^#([0-9a-f]{3}|[0-9a-f]{6})$/i', $form['primary_color'])
2026-02-04 23:31:53 +01:00
) {
$errors[] = t('Primary color is invalid');
}
if ($form['default_theme'] !== '' && !$this->settingsGateway->isAllowedTheme((string) $form['default_theme'])) {
$errors[] = t('Default theme is invalid');
}
2026-02-04 23:31:53 +01:00
return $errors;
}
2026-02-23 12:58:19 +01:00
private function normalizeStatus(string $status): string
2026-02-04 23:31:53 +01:00
{
2026-03-04 15:56:58 +01:00
return TenantStatus::normalizeOr($status, TenantStatus::Active)->value;
2026-02-23 12:58:19 +01:00
}
2026-02-04 23:31:53 +01:00
}