diff --git a/bin/scheduler-run.php b/bin/scheduler-run.php index db5a406..4cee042 100755 --- a/bin/scheduler-run.php +++ b/bin/scheduler-run.php @@ -10,11 +10,12 @@ require 'config/config.php'; require 'lib/Support/helpers.php'; use MintyPHP\DB; -use MintyPHP\Service\Scheduler\SchedulerRunService; +use MintyPHP\Service\Scheduler\SchedulerServicesFactory; $exitCode = 0; try { - $result = SchedulerRunService::runDueJobs(); + $factory = new SchedulerServicesFactory(); + $result = $factory->createSchedulerRunService()->runDueJobs(); if (!($result['ok'] ?? false)) { $error = (string) ($result['error'] ?? 'unexpected_error'); fwrite(STDERR, sprintf("scheduler run failed: %s\n", $error)); diff --git a/docs/architektur.md b/docs/architektur.md index 3bb3ecf..041266f 100644 --- a/docs/architektur.md +++ b/docs/architektur.md @@ -1,6 +1,6 @@ # Architektur -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-02-22 (geprüft) ## 1) Zielbild @@ -32,16 +32,18 @@ Ablauf pro Request: ## 3) Routing-Modell -- Route-Definitionen liegen in `/config/routes.php`. -- Registrierung erfolgt in `/config/router.php`. -- `public=true` in `routes.php` steuert, welche Pfade ohne Login erlaubt sind. +- Route-Definitionen (Pfad-Aliase) liegen in `/config/routes.php`. +- Registrierung erfolgt in `/config/router.php` (liest nur `path` und `target`; das `public`-Feld wird vom Router nicht ausgewertet). +- Welche Pfade ohne Login erreichbar sind, steuert `lib/Http/AccessControl.php`: + - Hartkodierte `ALWAYS_PUBLIC_PREFIXES` (z. B. `auth/microsoft/`, `api/`, `branding/`, `flash/`) + - `APP_PUBLIC_PATHS`-Konstante (gesetzt in `config/config.php`) MintyPHP-Konvention (Dateinamensrouting): - `pages/admin/users/edit($id).php` erwartet URL-Parameter `id` - `pages/admin/users/data().php` für Datenendpunkte - `...(none).phtml` für responses ohne Template-Layout -- Binarausgaben (z. B. Onboarding-PDF/ZIP) setzen `MINTY_ALLOW_OUTPUT` im Action-Endpoint. +- Binärausgaben (z. B. Onboarding-PDF/ZIP) setzen `MINTY_ALLOW_OUTPUT` im Action-Endpoint. ## 4) Schichten und Verantwortungen @@ -57,6 +59,15 @@ MintyPHP-Konvention (Dateinamensrouting): - Geschäftsregeln - Cross-Cutting-Logik (z. B. Auth, Settings, Branding) - Orchestrierung mehrerer Repositories +- Scheduler/Lifecycle sind instanzbasiert über `lib/Service/Scheduler/SchedulerServicesFactory.php` verdrahtet. +- Import/Import-Audit sind instanzbasiert über `lib/Service/Import/ImportServicesFactory.php` verdrahtet. +- User-Domain ist instanzbasiert über `lib/Service/User/UserServicesFactory.php` verdrahtet + (Services: `UserAccountService`, `UserAssignmentService`, `UserTenantContextService`, `UserPasswordService`; + Repositories: `UserReadRepository`, `UserWriteRepository`, `UserListQueryRepository` + Assignment-Repositories). +- Auth-/SSO-Domain ist instanzbasiert über `lib/Service/Auth/AuthServicesFactory.php` verdrahtet + (u. a. `AuthService`, `RememberMeService`, `SsoUserLinkService`, `TenantSsoService`, `MicrosoftOidcService`). + - Auth-Infra-Repositories (`ApiTokenRepository`, `RememberTokenRepository`, `PasswordResetRepository`, + `EmailVerificationRepository`) werden ebenfalls instanzbasiert über die Factory injiziert. ### Repository-Layer (`/lib/Repository`) @@ -180,8 +191,8 @@ So bleibt die Trennung stabil und Refactoring kontrollierbar. - `settings` (DB) ist die alleinige fachliche Quelle für globale Settings. - `config/settings.php` ist ein technischer Teil-Cache für sehr häufig gelesene UI-Basiswerte. - Zugriffsmuster: - - `SettingService::get*` / `SettingService::set*` -> DB - - `appSetting(...)` -> Datei-Cache (`SettingCacheService`) + - `SettingGateway` (verdrahtet über `SettingServicesFactory`) -> `SettingService` -> DB + - `appSetting(...)` -> `SettingServicesFactory` -> `SettingCacheService` (Datei-Cache) - Konsequenz: - Es ist korrekt, dass nicht alle DB-Settings im Datei-Cache stehen (z. B. SMTP/SSO/API-Details). - Der Cache muss nur die Keys enthalten, die im Runtime-Helper `appSetting(...)` verwendet werden. diff --git a/docs/auth-sso-smoke-test.md b/docs/auth-sso-smoke-test.md new file mode 100644 index 0000000..dd6873c --- /dev/null +++ b/docs/auth-sso-smoke-test.md @@ -0,0 +1,78 @@ +# Auth/SSO Smoke-Test + +Letzte Aktualisierung: 2026-02-22 + +Dieser Runbook prüft die 5 kritischen Flows nach Auth/SSO-Änderungen: +- Login (Passwort) +- Microsoft Start +- Microsoft Callback +- Tenant-SSO speichern +- Logout + +## Voraussetzungen + +- Test-User ist aktiv und hat mindestens einen aktiven Tenant. +- Es gibt einen Tenant mit Microsoft-SSO-Konfiguration. +- Für Microsoft-Flow: gültige Tenant-SSO-Credentials vorhanden (Shared oder Override). +- Testumgebung läuft und du bist als Admin eingeloggt. + +## 1) Login (Passwort) + +1. `login` aufrufen (optional `login?tenant=`). +2. Gültige E-Mail + Passwort eingeben. +3. Erwartung: + - Login erfolgreich, Redirect auf `admin`. + - Session enthält `user`, `available_tenants`, `current_tenant`. + - Kein Fehler-Flash. + +Negativtest: +- Falsches Passwort -> generischer Fehler (kein Account-Leak), kein Login. + +## 2) Microsoft Start (`auth/microsoft/start`) + +1. `auth/microsoft/start?tenant=` aufrufen. +2. Erwartung: + - Bei gültigem Tenant + SSO-Config: Redirect zur Microsoft-Authorize-URL. + - URL enthält `state`, `nonce`, PKCE (`code_challenge`). +3. Negativtest: + - Ungültiger Tenant-Slug -> Redirect auf `login` mit passendem Flash. + - SSO deaktiviert/inkomplett -> Redirect zurück auf Tenant-Login mit passendem Flash. + +## 3) Microsoft Callback (`auth/microsoft/callback`) + +1. Erfolgreichen Callback durchlaufen lassen. +2. Erwartung: + - `state` wird einmalig konsumiert (kein Replay). + - ID-Token-Prüfungen greifen (`aud`, `iss`, `tid`, `exp/nbf`, `nonce`, Signatur). + - User wird verknüpft/provisioniert und auf `admin` weitergeleitet. + +Negativtests: +- Callback mit altem/wiederverwendetem `state` -> Login schlägt sauber fehl. +- Callback ohne `code`/mit Fehler -> Redirect `login` mit Fehler-Flash. + +## 4) Tenant-SSO speichern (Admin) + +1. `admin/tenants/edit/` öffnen, Tab `SSO`. +2. Pflichtwerte setzen: + - `Microsoft login` aktiv + - `Entra tenant ID` + - Shared App oder Override-Credentials vollständig +3. Speichern. +4. Erwartung: + - Save erfolgreich. + - UI-Status „Konfiguration vollständig“. + - Bei `enforce_microsoft_login=1` und unvollständiger Config: Save wird blockiert. + +## 5) Logout + +1. Als eingeloggter User `logout` aufrufen. +2. Erwartung: + - Session beendet. + - Remember-Me Token wird vergessen. + - Redirect auf `login`. + +## Schnellcheck (optional) + +- In Browser A einloggen, dann in Browser B denselben User ändern (z. B. deaktivieren). +- In Browser A nächste Anfrage auslösen. +- Erwartung: bestehende Session-Refresh-Logik greift (ggf. Logout bei inaktiv/ohne Tenant). diff --git a/docs/benutzer-lifecycle-policy.md b/docs/benutzer-lifecycle-policy.md index 08f57a0..4192ae1 100644 --- a/docs/benutzer-lifecycle-policy.md +++ b/docs/benutzer-lifecycle-policy.md @@ -1,6 +1,6 @@ # Benutzer-Lifecycle-Policy -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-02-22 ## Ziel @@ -45,6 +45,7 @@ Automatische Lifecycle-Aktionen ignorieren Benutzer mit mindestens einer aktiven - Endpoint: `POST /admin/settings/run-user-lifecycle` - Schutz: Login + `settings.update` + CSRF +- Verdrahtung: `SchedulerServicesFactory` erstellt `UserLifecycleService` instanzbasiert. ### Cron/CLI diff --git a/docs/einstellungen-speicherung.md b/docs/einstellungen-speicherung.md index f65d07e..22f6624 100644 --- a/docs/einstellungen-speicherung.md +++ b/docs/einstellungen-speicherung.md @@ -1,6 +1,6 @@ # Einstellungen: Speicherung (DB + Cache) -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-02-22 ## Kurzfassung @@ -41,8 +41,8 @@ Daher ist es korrekt, wenn diese Werte in `settings` (DB) vorhanden sind, aber n ## Laufzeitfluss 1. Admin speichert Settings in `admin/settings`. -2. `SettingService::set*` schreibt in DB. -3. `SettingCacheService::update(...)` aktualisiert den Datei-Cache nur für den definierten Teilbereich. +2. `SettingGateway` (aus `SettingServicesFactory`) delegiert auf `SettingService`, der in DB schreibt. +3. `SettingCacheService` aktualisiert den Datei-Cache nur für den definierten Teilbereich. 4. UI-Helfer `appSetting(...)` liest danach aus Datei-Cache. ## Wenn DB und Datei scheinbar nicht zusammenpassen diff --git a/docs/erste-aenderung.md b/docs/erste-aenderung.md index e579dcb..65d28d1 100644 --- a/docs/erste-aenderung.md +++ b/docs/erste-aenderung.md @@ -1,6 +1,6 @@ # Leitfaden für die erste Änderung -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-02-22 ## Ziel @@ -11,7 +11,10 @@ Ein neuer Entwickler soll den ersten Change strukturiert und reproduzierbar umse ### Schrittfolge 1. Data-Endpoint erweitern (z. B. `pages/admin/users/data().php`) -2. Falls nötig: neue Repository-Query/Count-Methode (z. B. `lib/Repository/User/UserRepository.php`) +2. Falls nötig: neue Repository-Methode ergänzen – je nach Typ: + - Listenabfrage / Filterung / Paginierung → `lib/Repository/User/UserListQueryRepository.php` + - Einzelnes Objekt lesen → `lib/Repository/User/UserReadRepository.php` + - Schreiboperation → `lib/Repository/User/UserWriteRepository.php` 3. Grid-Spalte in `pages/admin//index(default).phtml` ergänzen (z. B. `pages/admin/users/index(default).phtml`) 4. i18n-Key für Spaltennamen setzen 5. `php -l` + manueller UI-Test diff --git a/docs/geplante-aufgaben.md b/docs/geplante-aufgaben.md index 6976e8f..023cf41 100644 --- a/docs/geplante-aufgaben.md +++ b/docs/geplante-aufgaben.md @@ -84,7 +84,7 @@ Unterstützte Typen: ### Architektur-überblick Jeder Job ist eine eigene Handler-Klasse, die `ScheduledJobHandlerInterface` implementiert. -Die Registry ist eine reine Map von `job_key` zu Handler-Klasse – sie enthält keine +Die Registry ist eine Map von `job_key` zu Handler-Instanz – sie enthält keine job-spezifische Logik. ``` @@ -93,6 +93,7 @@ lib/Service/Scheduler/ ScheduledJobHandlerInterface.php ← Vertrag für alle Handler UserLifecycleJobHandler.php ← Referenz-Implementierung ScheduledJobRegistry.php ← Handler-Map (einzige Datei die bei neuen Jobs angefasst wird) + SchedulerServicesFactory.php ← verdrahtet Abhängigkeiten zentral ``` ### Schritt-für-Schritt @@ -110,7 +111,11 @@ use MintyPHP\Service\MeinBereich\MeinService; class MeinJobHandler implements ScheduledJobHandlerInterface { - public static function definition(): array + public function __construct(private readonly MeinService $meinService) + { + } + + public function definition(): array { return [ 'label' => 'Mein Job', @@ -126,9 +131,9 @@ class MeinJobHandler implements ScheduledJobHandlerInterface ]; } - public static function execute(?int $actorUserId): array + public function execute(?int $actorUserId): array { - $result = MeinService::run($actorUserId); + $result = $this->meinService->run($actorUserId); if (!($result['ok'] ?? false)) { $errorCode = (string) ($result['error'] ?? 'job_failed'); @@ -162,20 +167,22 @@ Datei: `lib/Service/Scheduler/ScheduledJobRegistry.php` // Konstante hinzufügen: public const MEIN_JOB = 'mein_job'; -// In handlers() eine Zeile hinzufügen: -private static function handlers(): array -{ - return [ - self::USER_LIFECYCLE_RUN => UserLifecycleJobHandler::class, - self::MEIN_JOB => MeinJobHandler::class, // ← neu - ]; -} +// In __construct() eine Zeile hinzufügen: +$this->handlers = [ + self::USER_LIFECYCLE_RUN => new UserLifecycleJobHandler($userLifecycleService), + self::MEIN_JOB => new MeinJobHandler($meinService), // ← neu +]; ``` -**3. Fertig.** +**3. Factory-Verdrahtung ergänzen** -Keine anderen Dateien müssen angefasst werden. `ensureSystemJobs()` legt den Job beim -nächsten Scheduler-Aufruf automatisch in der Datenbank an. +Datei: `lib/Service/Scheduler/SchedulerServicesFactory.php` + +- `MeinService` instanziieren bzw. bereitstellen +- beim Erzeugen von `ScheduledJobRegistry` den Service mit übergeben + +Danach legt `ensureSystemJobs()` den Job beim nächsten Scheduler-Aufruf automatisch +in der Datenbank an. ### Regeln für Handler @@ -202,7 +209,8 @@ nächsten Scheduler-Aufruf automatisch in der Datenbank an. `lib/Service/Scheduler/Handler/UserLifecycleJobHandler.php` ist die vollständige Referenz-Implementierung mit internem Service-Lock, Fehlerbehandlung und -job-spezifischem `result`-Payload. +job-spezifischem `result`-Payload. Der Objektgraph wird über +`lib/Service/Scheduler/SchedulerServicesFactory.php` gebaut. --- @@ -219,6 +227,6 @@ job-spezifischem `result`-Payload. - `lock_not_acquired`: - Ein anderer Runner läuft bereits. - Job bleibt auf `running`: - - stale-running Schutz erlaubt nach 2 Stunden wieder neue Runs (Schwellwert in `ScheduledJobRepository::markRunning`). + - stale-running Schutz erlaubt nach 2 Stunden wieder neue Runs (Schwellwert in `ScheduledJobRepository->markRunning`). - Kein fälliger Job: - `next_run_at`, `enabled`, `timezone` und schedule Konfiguration prüfen. diff --git a/docs/importe.md b/docs/importe.md index d91128b..d7e1e8c 100644 --- a/docs/importe.md +++ b/docs/importe.md @@ -1,9 +1,17 @@ # Importe -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-02-22 Diese Seite beschreibt den V1 CSV-Import unter `/admin/imports`. +## Technischer Aufbau + +- Das Import-Modul ist instanzbasiert über `lib/Service/Import/ImportServicesFactory.php` verdrahtet. +- `ImportService` orchestriert Analyze/Preview/Commit. +- `ImportStateStoreService` kapselt den tokenbasierten Session-State (`admin_imports_v1`, TTL 2h). +- `CsvReaderService` und `ImportTempFileService` sind eigene Instanzservices. +- Import-Audit läuft über `ImportAuditService` + `ImportAuditRunRepository`. + ## Scope (V1) - Profile: `users`, `departments`. diff --git a/docs/index.md b/docs/index.md index 34db1c1..7c2a686 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # Dokumentation -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-02-22 Diese Dokumente sind für Entwickler gedacht, die die Anwendung warten oder erweitern. @@ -63,6 +63,8 @@ Diese Dokumente sind für Entwickler gedacht, die die Anwendung warten oder erwe - Alle ENV-Variablen mit Beschreibung, Standardwerten und Produktions-Checkliste. - `/docs/fehlerbehebung.md` - Häufige Fehlerbilder und schnelle Lösungen. +- `/docs/auth-sso-smoke-test.md` + - Manueller Smoke-Runbook für Login, Microsoft-Start/Callback, Tenant-SSO-Save und Logout. - `/docs/docker-lokal.md` - Schneller lokaler Docker-Start inkl. typischer Befehle und Troubleshooting. - `/docs/docker-produktiv.md` diff --git a/docs/konventionen.md b/docs/konventionen.md index 0cb8f3e..daf918f 100644 --- a/docs/konventionen.md +++ b/docs/konventionen.md @@ -25,6 +25,8 @@ Diese Regeln sind projektweit verbindlich, um Lesbarkeit und Wartbarkeit stabil - Keine HTML-Ausgabe. - Keine direkten View-Annahmen. - Bei globalen Settings bleibt DB die Quelle (`SettingService`); Datei-Cache nur gezielt für `appSetting(...)`. +- Für neue oder refaktorierte Domain-Module Instanz-Services + Factory verwenden (z. B. `...ServicesFactory`) statt statischer Domain-Methoden. +- Keine domain-spezifischen Service-Locator-Helper in `lib/Support/helpers/app.php`; Composition per Factory im Aufrufer. ### Action (`pages/**.php`) @@ -121,6 +123,7 @@ Ausführen bei: Feature-Entfernung, größerem Refactoring oder vor Releases. Pa ## 8) Settings-Konvention - Neue Settings immer zuerst sauber im `SettingService` modellieren (Validierung + Read/Write). -- Nur dann in `SettingCacheService::update(...)` aufnehmen, wenn der Key wirklich über `appSetting(...)` im Hot Path genutzt wird. +- Aufrufer nutzen `SettingGateway` aus `SettingServicesFactory`; kein globaler `settingGateway()`-Helper. +- Nur dann in den `SettingCacheService` aufnehmen, wenn der Key wirklich über `appSetting(...)` im Hot Path genutzt wird. - Keine Sicherheits-/Integrationssettings (SMTP, SSO-Secrets, API-Policies) als Pflicht in den Datei-Cache drücken. - Bei manuellen DB-Änderungen an gecachten Keys den Cache gezielt aktualisieren (z. B. über Settings-Save-Flow). diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 24b2cc1..479ef62 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -10,6 +10,8 @@ servers: security: - BearerAuth: [] tags: + - name: auth + description: Public authentication endpoints (no Bearer token required) - name: me - name: tokens - name: users @@ -17,6 +19,60 @@ tags: - name: departments - name: roles paths: + /auth/login: + post: + tags: [auth] + summary: Login with email and password + description: | + Authenticates a user with email and password and returns a Bearer token. + This endpoint is **public** — no Authorization header required. + + The returned token can be used immediately as `Authorization: Bearer ` + for all subsequent API requests. + + Rate limits (stricter than general API limits): + - 10 failed attempts per IP per 10 minutes + - 5 failed attempts per email+IP per 15 minutes + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + example: + email: user@example.com + password: "..." + token_name: "My App" + responses: + '201': + description: Login successful — token issued + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalid_credentials: + value: + error: invalid_credentials + account_inactive: + value: + error: account_inactive + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/ValidationError' + '429': + $ref: '#/components/responses/RateLimited' + /me: get: tags: [me] @@ -833,6 +889,47 @@ components: data: type: object additionalProperties: true + LoginRequest: + type: object + additionalProperties: false + required: [email, password] + properties: + email: + type: string + format: email + description: User email address (case-insensitive) + password: + type: string + description: User password + token_name: + type: string + description: Optional label for the issued token (e.g. "iPhone App"). Defaults to "Login". + default: Login + tenant_uuid: + type: string + format: uuid + description: Optional — scope the issued token to a specific tenant UUID. Must be assigned to the user. + LoginResponse: + type: object + required: [data] + properties: + data: + type: object + required: [token, token_uuid, expires_at] + properties: + token: + type: string + description: Full Bearer token in format `selector:plaintext`. Shown only once — store securely. + example: "abc123def:456789..." + token_uuid: + type: string + format: uuid + description: UUID of the created token record (use for revocation via /me/tokens) + expires_at: + type: string + format: date-time + nullable: true + description: UTC expiry timestamp of the token RoleWriteRequest: type: object additionalProperties: false diff --git a/docs/sicherheitsmodell.md b/docs/sicherheitsmodell.md index fe77f5a..4deea15 100644 --- a/docs/sicherheitsmodell.md +++ b/docs/sicherheitsmodell.md @@ -1,6 +1,6 @@ # Sicherheitsmodell -Letzte Aktualisierung: 2026-02-21 +Letzte Aktualisierung: 2026-02-22 ## 1) Grundprinzip @@ -21,6 +21,8 @@ Kein einzelner Layer ist ausreichend; die Kombination ist der Schutz. - Auto-Login aus Cookie erfolgt früh in `/web/index.php`. - Zentraler Login-Entry ist `login` (optional `login?tenant=` für Tenant-Vorauswahl). - Unknown-Email-Verhalten im Login bleibt generisch (keine Tenant-/Account-Details vor erfolgreicher Auflösung). +- Auth-/SSO-Kern ist instanzbasiert verdrahtet über `lib/Service/Auth/AuthServicesFactory.php` + (`AuthService`, `RememberMeService`, `SsoUserLinkService`, `TenantSsoService`, `MicrosoftOidcService`). Kritisch: diff --git a/lib/Http/ApiAuth.php b/lib/Http/ApiAuth.php index 2cfb418..140c687 100644 --- a/lib/Http/ApiAuth.php +++ b/lib/Http/ApiAuth.php @@ -2,11 +2,10 @@ namespace MintyPHP\Http; -use MintyPHP\Repository\Access\RolePermissionRepository; -use MintyPHP\Repository\Access\UserRoleRepository; -use MintyPHP\Service\Auth\ApiTokenService; -use MintyPHP\Service\Tenant\TenantScopeService; -use MintyPHP\Service\User\UserService; +use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Auth\AuthScopeGateway; +use MintyPHP\Service\Auth\AuthServicesFactory; +use MintyPHP\Service\User\UserServicesFactory; class ApiAuth { @@ -50,29 +49,35 @@ class ApiAuth return false; } - $result = ApiTokenService::validate($bearerToken); + $authServicesFactory = new AuthServicesFactory(); + $scopeGateway = $authServicesFactory->createAuthScopeGateway(); + $result = $authServicesFactory->createApiTokenService()->validate($bearerToken); if ($result === null) { return false; } $user = $result['user']; $userId = (int) ($user['id'] ?? 0); + $userServicesFactory = new UserServicesFactory(); + $userRoleRepository = $userServicesFactory->createUserRoleRepository(); + $rolePermissionRepository = (new AccessServicesFactory())->createRolePermissionRepository(); + $userTenantContextService = $userServicesFactory->createUserTenantContextService(); // Load permissions directly (bypass session cache) - $roleIds = UserRoleRepository::listRoleIdsByUserId($userId); - $permissions = RolePermissionRepository::listPermissionKeysByRoleIds($roleIds); + $roleIds = $userRoleRepository->listRoleIdsByUserId($userId); + $permissions = $rolePermissionRepository->listPermissionKeysByRoleIds($roleIds); // Resolve tenant context $tokenTenantId = $result['tenant_id']; if ($tokenTenantId !== null) { - $userTenantIds = TenantScopeService::getUserTenantIds($userId); + $userTenantIds = $scopeGateway->getUserTenantIds($userId); if (!in_array($tokenTenantId, $userTenantIds, true)) { return false; } $currentTenantId = $tokenTenantId; self::$tokenTenantId = $tokenTenantId; } else { - $currentTenantId = UserService::getCurrentTenantId($userId); + $currentTenantId = $userTenantContextService->getCurrentTenantId($userId); self::$tokenTenantId = null; } @@ -141,7 +146,8 @@ class ApiAuth */ public static function requireResourceAccess(string $resource, int $resourceId): void { - if (!TenantScopeService::canAccess($resource, $resourceId, self::userId())) { + $scopeGateway = (new AuthServicesFactory())->createAuthScopeGateway(); + if (!$scopeGateway->canAccess($resource, $resourceId, self::userId())) { ApiResponse::notFound(); } self::requireTokenTenantAccess($resource, $resourceId); @@ -158,7 +164,8 @@ class ApiAuth ApiResponse::forbidden(); } - if (!TenantScopeService::resourceBelongsToTenant($resource, $resourceId, $tenantId)) { + $scopeGateway = (new AuthServicesFactory())->createAuthScopeGateway(); + if (!$scopeGateway->resourceBelongsToTenant($resource, $resourceId, $tenantId)) { ApiResponse::notFound(); } } diff --git a/lib/Http/ApiBootstrap.php b/lib/Http/ApiBootstrap.php index 08aaed1..710708d 100644 --- a/lib/Http/ApiBootstrap.php +++ b/lib/Http/ApiBootstrap.php @@ -2,9 +2,10 @@ namespace MintyPHP\Http; -use MintyPHP\Service\Audit\ApiAuditService; use MintyPHP\Service\Security\RateLimiterService; -use MintyPHP\Service\Settings\SettingService; +use MintyPHP\Service\Security\SecurityServicesFactory; +use MintyPHP\Service\Settings\SettingGateway; +use MintyPHP\Service\Settings\SettingServicesFactory; class ApiBootstrap { @@ -17,6 +18,10 @@ class ApiBootstrap private static bool $initialized = false; private static bool $shutdownRegistered = false; + private static ?SecurityServicesFactory $securityServicesFactory = null; + private static ?RateLimiterService $rateLimiterService = null; + private static ?SettingServicesFactory $settingServicesFactory = null; + private static ?SettingGateway $settingGateway = null; /** * Initialize the API request context. @@ -36,7 +41,7 @@ class ApiBootstrap } self::registerShutdownHandler(); - ApiAuditService::startRequestContext(); + \auditServicesFactory()->createApiAuditService()->startRequestContext(); self::setCorsHeaders(); if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { @@ -63,7 +68,7 @@ class ApiBootstrap return; } - $allowedOrigins = array_fill_keys(SettingService::getApiCorsAllowedOrigins(), true); + $allowedOrigins = array_fill_keys(self::settings()->getApiCorsAllowedOrigins(), true); if (!isset($allowedOrigins[$origin])) { return; } @@ -89,7 +94,7 @@ class ApiBootstrap if ($statusCode <= 0) { $statusCode = 200; } - ApiAuditService::finish($statusCode); + \auditServicesFactory()->createApiAuditService()->finish($statusCode); return; } @@ -100,7 +105,7 @@ class ApiBootstrap if ($statusCode <= 0) { $statusCode = 200; } - ApiAuditService::finish($statusCode); + \auditServicesFactory()->createApiAuditService()->finish($statusCode); return; } @@ -108,7 +113,7 @@ class ApiBootstrap if ($statusCode < 400) { $statusCode = 500; } - ApiAuditService::finish($statusCode, 'fatal_error'); + \auditServicesFactory()->createApiAuditService()->finish($statusCode, 'fatal_error'); }); } @@ -154,7 +159,7 @@ class ApiBootstrap $ip = 'unknown'; } - $ipResult = RateLimiterService::hit( + $ipResult = self::rateLimiter()->hit( self::API_RATE_SCOPE_IP, $ip, self::API_RATE_LIMIT_IP, @@ -170,7 +175,7 @@ class ApiBootstrap return; } - $tokenResult = RateLimiterService::hit( + $tokenResult = self::rateLimiter()->hit( self::API_RATE_SCOPE_TOKEN, strtolower($selector) . '|' . $ip, self::API_RATE_LIMIT_TOKEN, @@ -198,4 +203,32 @@ class ApiBootstrap return $selector; } + + private static function settings(): SettingGateway + { + if (self::$settingGateway instanceof SettingGateway) { + return self::$settingGateway; + } + + if (!(self::$settingServicesFactory instanceof SettingServicesFactory)) { + self::$settingServicesFactory = new SettingServicesFactory(); + } + + self::$settingGateway = self::$settingServicesFactory->createSettingGateway(); + return self::$settingGateway; + } + + private static function rateLimiter(): RateLimiterService + { + if (self::$rateLimiterService instanceof RateLimiterService) { + return self::$rateLimiterService; + } + + if (!(self::$securityServicesFactory instanceof SecurityServicesFactory)) { + self::$securityServicesFactory = new SecurityServicesFactory(); + } + + self::$rateLimiterService = self::$securityServicesFactory->createRateLimiterService(); + return self::$rateLimiterService; + } } diff --git a/lib/Http/ApiResponse.php b/lib/Http/ApiResponse.php index 11d66de..893e241 100644 --- a/lib/Http/ApiResponse.php +++ b/lib/Http/ApiResponse.php @@ -2,8 +2,6 @@ namespace MintyPHP\Http; -use MintyPHP\Service\Audit\ApiAuditService; - class ApiResponse { public static function success(array $data = [], int $status = 200): never @@ -139,7 +137,7 @@ class ApiResponse } if ($body === null) { - ApiAuditService::finish($status, $errorCode); + \auditServicesFactory()->createApiAuditService()->finish($status, $errorCode); die(); } @@ -152,7 +150,7 @@ class ApiResponse } header('Content-Type: application/json; charset=utf-8'); - ApiAuditService::finish($status, $errorCode); + \auditServicesFactory()->createApiAuditService()->finish($status, $errorCode); die($json); } } diff --git a/lib/Repository/Access/PermissionRepository.php b/lib/Repository/Access/PermissionRepository.php index 576f8f8..dcc2b58 100644 --- a/lib/Repository/Access/PermissionRepository.php +++ b/lib/Repository/Access/PermissionRepository.php @@ -7,7 +7,7 @@ use MintyPHP\Repository\Support\RepoQuery; class PermissionRepository { - public static function list(): array + public function list(): array { $rows = DB::select('select id, `key`, description, active, is_system, created from permissions order by `key` asc'); if (!is_array($rows)) { @@ -23,7 +23,7 @@ class PermissionRepository return $list; } - public static function listActive(): array + public function listActive(): array { $rows = DB::select( 'select id, `key`, description, active, is_system, created from permissions where active = 1 order by `key` asc' @@ -41,7 +41,7 @@ class PermissionRepository return $list; } - public static function listPaged(array $options): array + public function listPaged(array $options): array { $search = trim((string) ($options['search'] ?? '')); $allowedOrder = [ @@ -79,7 +79,7 @@ class PermissionRepository return ['data' => $list, 'total' => (int) $total]; } - public static function find(int $id): ?array + public function find(int $id): ?array { $row = DB::selectOne( 'select id, `key`, description, active, is_system, created from permissions where id = ? limit 1', @@ -89,7 +89,7 @@ class PermissionRepository return is_array($data) ? $data : null; } - public static function findByKey(string $key): ?array + public function findByKey(string $key): ?array { $row = DB::selectOne( 'select id, `key`, description, active, is_system, created from permissions where `key` = ? limit 1', @@ -99,7 +99,7 @@ class PermissionRepository return is_array($data) ? $data : null; } - public static function create(array $data): ?int + public function create(array $data): ?int { $key = trim((string) ($data['key'] ?? '')); $desc = trim((string) ($data['description'] ?? '')); @@ -115,7 +115,7 @@ class PermissionRepository return $result ? (int) $result : null; } - public static function update(int $id, array $data): bool + public function update(int $id, array $data): bool { $key = trim((string) ($data['key'] ?? '')); $desc = trim((string) ($data['description'] ?? '')); @@ -132,7 +132,7 @@ class PermissionRepository return $result !== false; } - public static function delete(int $id): bool + public function delete(int $id): bool { $result = DB::delete('delete from permissions where id = ?', (string) $id); return (bool) $result; diff --git a/lib/Repository/Access/RolePermissionRepository.php b/lib/Repository/Access/RolePermissionRepository.php index 6a6d06e..78ff87b 100644 --- a/lib/Repository/Access/RolePermissionRepository.php +++ b/lib/Repository/Access/RolePermissionRepository.php @@ -6,7 +6,7 @@ use MintyPHP\DB; class RolePermissionRepository { - public static function listPermissionIdsByRoleId(int $roleId): array + public function listPermissionIdsByRoleId(int $roleId): array { $rows = DB::select('select permission_id from role_permissions where role_id = ?', (string) $roleId); if (!is_array($rows)) { @@ -22,7 +22,7 @@ class RolePermissionRepository return array_values(array_unique($ids)); } - public static function countPermissionsByRoleIds(array $roleIds): array + public function countPermissionsByRoleIds(array $roleIds): array { $roleIds = array_values(array_unique(array_map('intval', $roleIds))); $roleIds = array_values(array_filter($roleIds, static fn ($id) => $id > 0)); @@ -54,7 +54,7 @@ class RolePermissionRepository return $result; } - public static function replaceForRole(int $roleId, array $permissionIds): bool + public function replaceForRole(int $roleId, array $permissionIds): bool { DB::delete('delete from role_permissions where role_id = ?', (string) $roleId); $ids = array_values(array_unique(array_filter(array_map('intval', $permissionIds)))); @@ -71,7 +71,7 @@ class RolePermissionRepository return true; } - public static function listRoleIdsByPermissionId(int $permissionId): array + public function listRoleIdsByPermissionId(int $permissionId): array { $rows = DB::select('select role_id from role_permissions where permission_id = ?', (string) $permissionId); if (!is_array($rows)) { @@ -87,7 +87,7 @@ class RolePermissionRepository return array_values(array_unique($ids)); } - public static function replaceForPermission(int $permissionId, array $roleIds): bool + public function replaceForPermission(int $permissionId, array $roleIds): bool { DB::delete('delete from role_permissions where permission_id = ?', (string) $permissionId); $ids = array_values(array_unique(array_filter(array_map('intval', $roleIds)))); @@ -104,7 +104,7 @@ class RolePermissionRepository return true; } - public static function listPermissionKeysByRoleIds(array $roleIds): array + public function listPermissionKeysByRoleIds(array $roleIds): array { $ids = array_values(array_unique(array_filter(array_map('intval', $roleIds)))); if (!$ids) { @@ -132,7 +132,7 @@ class RolePermissionRepository return array_values(array_unique($keys)); } - public static function listPermissionsWithRolesByRoleIds(array $roleIds): array + public function listPermissionsWithRolesByRoleIds(array $roleIds): array { $ids = array_values(array_unique(array_filter(array_map('intval', $roleIds)))); if (!$ids) { @@ -179,7 +179,7 @@ class RolePermissionRepository return array_values($permMap); } - private static function extractIntField(array $row, string $field): int + private function extractIntField(array $row, string $field): int { foreach ($row as $value) { if (!is_array($value)) { diff --git a/lib/Repository/Access/RoleRepository.php b/lib/Repository/Access/RoleRepository.php index 337e7bc..54fb81d 100644 --- a/lib/Repository/Access/RoleRepository.php +++ b/lib/Repository/Access/RoleRepository.php @@ -7,7 +7,7 @@ use MintyPHP\Repository\Support\RepoQuery; class RoleRepository { - private static function unwrap(?array $row): ?array + private function unwrap(?array $row): ?array { if (!$row) { return null; @@ -15,7 +15,7 @@ class RoleRepository return $row['roles'] ?? null; } - private static function unwrapList($rows): array + private function unwrapList($rows): array { if (!is_array($rows)) { return []; @@ -30,23 +30,23 @@ class RoleRepository return $list; } - public static function list(): array + public function list(): array { $rows = DB::select( 'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles order by id desc' ); - return self::unwrapList($rows); + return $this->unwrapList($rows); } - public static function listActive(): array + public function listActive(): array { $rows = DB::select( 'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles where active = 1 order by description asc' ); - return self::unwrapList($rows); + return $this->unwrapList($rows); } - public static function listByIds(array $roleIds): array + public function listByIds(array $roleIds): array { $roleIds = array_values(array_unique(array_map('intval', $roleIds))); $roleIds = array_values(array_filter($roleIds, static fn ($id) => $id > 0)); @@ -59,10 +59,10 @@ class RoleRepository 'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles where id in (' . $placeholders . ')', ...array_map('strval', $roleIds) ); - return self::unwrapList($rows); + return $this->unwrapList($rows); } - public static function listIds(): array + public function listIds(): array { $rows = DB::select('select id from roles'); if (!is_array($rows)) { @@ -78,7 +78,7 @@ class RoleRepository return array_values(array_unique($ids)); } - public static function listActiveIds(): array + public function listActiveIds(): array { $rows = DB::select('select id from roles where active = 1'); if (!is_array($rows)) { @@ -94,7 +94,7 @@ class RoleRepository return array_values(array_unique($ids)); } - public static function listPaged(array $options): array + public function listPaged(array $options): array { $search = trim((string) ($options['search'] ?? '')); $allowedOrder = ['id', 'uuid', 'description', 'code', 'active', 'created', 'modified']; @@ -123,29 +123,29 @@ class RoleRepository return [ 'total' => $total, - 'rows' => self::unwrapList($rows), + 'rows' => $this->unwrapList($rows), ]; } - public static function find(int $id): ?array + public function find(int $id): ?array { $row = DB::selectOne( 'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles where id = ? limit 1', (string) $id ); - return self::unwrap($row); + return $this->unwrap($row); } - public static function findByUuid(string $uuid): ?array + public function findByUuid(string $uuid): ?array { $row = DB::selectOne( 'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles where uuid = ? limit 1', $uuid ); - return self::unwrap($row); + return $this->unwrap($row); } - public static function existsByCode(string $code, int $excludeId = 0): bool + public function existsByCode(string $code, int $excludeId = 0): bool { $code = trim($code); if ($code === '') { @@ -159,7 +159,7 @@ class RoleRepository return $count ? ((int) $count > 0) : false; } - public static function create(array $data) + public function create(array $data) { return DB::insert( 'insert into roles (uuid, description, code, active, created_by, created) values (?,?,?,?,?,NOW())', @@ -171,7 +171,7 @@ class RoleRepository ); } - public static function update(int $id, array $data): bool + public function update(int $id, array $data): bool { $fields = [ 'description' => $data['description'], @@ -195,7 +195,7 @@ class RoleRepository return $result !== false; } - public static function delete(int $id): bool + public function delete(int $id): bool { $result = DB::delete('delete from roles where id = ?', (string) $id); return $result !== false; diff --git a/lib/Repository/Access/UserRoleRepository.php b/lib/Repository/Access/UserRoleRepository.php index efc9f19..2eb3002 100644 --- a/lib/Repository/Access/UserRoleRepository.php +++ b/lib/Repository/Access/UserRoleRepository.php @@ -6,7 +6,7 @@ use MintyPHP\DB; class UserRoleRepository { - public static function listRoleIdsByUserId(int $userId): array + public function listRoleIdsByUserId(int $userId): array { $rows = DB::select('select role_id from user_roles where user_id = ?', (string) $userId); if (!is_array($rows)) { @@ -22,7 +22,7 @@ class UserRoleRepository return array_values(array_unique($ids)); } - public static function replaceForUser(int $userId, array $roleIds): bool + public function replaceForUser(int $userId, array $roleIds): bool { DB::delete('delete from user_roles where user_id = ?', (string) $userId); if (!$roleIds) { @@ -40,17 +40,17 @@ class UserRoleRepository return true; } - public static function countUsersByRoleIds(array $roleIds): array + public function countUsersByRoleIds(array $roleIds): array { - return self::countByRoleIds($roleIds, false); + return $this->countByRoleIds($roleIds, false); } - public static function countActiveUsersByRoleIds(array $roleIds): array + public function countActiveUsersByRoleIds(array $roleIds): array { - return self::countByRoleIds($roleIds, true); + return $this->countByRoleIds($roleIds, true); } - private static function countByRoleIds(array $roleIds, bool $activeOnly): array + private function countByRoleIds(array $roleIds, bool $activeOnly): array { $roleIds = array_values(array_unique(array_map('intval', $roleIds))); $roleIds = array_values(array_filter($roleIds, static fn ($id) => $id > 0)); @@ -73,17 +73,17 @@ class UserRoleRepository $result = []; foreach ($rows as $row) { - $roleId = self::extractIntField($row, 'role_id'); + $roleId = $this->extractIntField($row, 'role_id'); if ($roleId <= 0) { continue; } - $result[$roleId] = self::extractIntField($row, 'user_count'); + $result[$roleId] = $this->extractIntField($row, 'user_count'); } return $result; } - private static function extractIntField(array $row, string $field): int + private function extractIntField(array $row, string $field): int { foreach ($row as $value) { if (!is_array($value)) { diff --git a/lib/Repository/Audit/ApiAuditLogRepository.php b/lib/Repository/Audit/ApiAuditLogRepository.php index ba69774..c6150f4 100644 --- a/lib/Repository/Audit/ApiAuditLogRepository.php +++ b/lib/Repository/Audit/ApiAuditLogRepository.php @@ -7,7 +7,7 @@ use MintyPHP\Repository\Support\RepoQuery; class ApiAuditLogRepository { - public static function create(array $data): int|false + public function create(array $data): int|false { $id = DB::insert( 'insert into api_audit_log ( @@ -32,7 +32,7 @@ class ApiAuditLogRepository return $id ? (int) $id : false; } - public static function listPaged(array $filters): array + public function listPaged(array $filters): array { $search = trim((string) ($filters['search'] ?? '')); $status = strtolower(trim((string) ($filters['status'] ?? ''))); @@ -134,7 +134,7 @@ class ApiAuditLogRepository $normalized = []; if (is_array($rows)) { foreach ($rows as $row) { - $item = self::normalizeRow($row); + $item = $this->normalizeRow($row); if ($item !== null) { $normalized[] = $item; } @@ -147,7 +147,7 @@ class ApiAuditLogRepository ]; } - public static function find(int $id): ?array + public function find(int $id): ?array { $row = DB::selectOne( 'select @@ -181,10 +181,10 @@ class ApiAuditLogRepository (string) $id ); - return self::normalizeRow($row); + return $this->normalizeRow($row); } - public static function purgeOlderThanDays(int $days): int + public function purgeOlderThanDays(int $days): int { if ($days <= 0) { return 0; @@ -197,7 +197,7 @@ class ApiAuditLogRepository return is_int($deleted) ? $deleted : 0; } - private static function normalizeRow(mixed $row): ?array + private function normalizeRow(mixed $row): ?array { if (!is_array($row)) { return null; @@ -220,4 +220,3 @@ class ApiAuditLogRepository return $log; } } - diff --git a/lib/Repository/Audit/ImportAuditRunRepository.php b/lib/Repository/Audit/ImportAuditRunRepository.php index fa2a882..8cc2c29 100644 --- a/lib/Repository/Audit/ImportAuditRunRepository.php +++ b/lib/Repository/Audit/ImportAuditRunRepository.php @@ -7,7 +7,7 @@ use MintyPHP\Repository\Support\RepoQuery; class ImportAuditRunRepository { - public static function createRunning(array $data): int|false + public function createRunning(array $data): int|false { $id = DB::insert( 'insert into import_audit_runs ( @@ -25,7 +25,7 @@ class ImportAuditRunRepository return $id ? (int) $id : false; } - public static function finishById(int $id, array $data): bool + public function finishById(int $id, array $data): bool { if ($id <= 0) { return false; @@ -55,7 +55,7 @@ class ImportAuditRunRepository return (int) $updated > 0; } - public static function listPaged(array $filters): array + public function listPaged(array $filters): array { $search = trim((string) ($filters['search'] ?? '')); $profileKey = strtolower(trim((string) ($filters['profile_key'] ?? ''))); @@ -157,7 +157,7 @@ class ImportAuditRunRepository $normalized = []; if (is_array($rows)) { foreach ($rows as $row) { - $item = self::normalizeRow($row); + $item = $this->normalizeRow($row); if ($item !== null) { $normalized[] = $item; } @@ -170,7 +170,7 @@ class ImportAuditRunRepository ]; } - public static function find(int $id): ?array + public function find(int $id): ?array { if ($id <= 0) { return null; @@ -209,10 +209,10 @@ class ImportAuditRunRepository (string) $id ); - return self::normalizeRow($row); + return $this->normalizeRow($row); } - public static function purgeOlderThanDays(int $days): int + public function purgeOlderThanDays(int $days): int { if ($days <= 0) { return 0; @@ -226,7 +226,7 @@ class ImportAuditRunRepository return is_int($deleted) ? $deleted : 0; } - private static function normalizeRow(mixed $row): ?array + private function normalizeRow(mixed $row): ?array { if (!is_array($row)) { return null; diff --git a/lib/Repository/Audit/UserLifecycleAuditRepository.php b/lib/Repository/Audit/UserLifecycleAuditRepository.php index f50b4ab..669b6d4 100644 --- a/lib/Repository/Audit/UserLifecycleAuditRepository.php +++ b/lib/Repository/Audit/UserLifecycleAuditRepository.php @@ -7,7 +7,7 @@ use MintyPHP\Repository\Support\RepoQuery; class UserLifecycleAuditRepository { - public static function create(array $row): int|false + public function create(array $row): int|false { $id = DB::insert( 'insert into user_lifecycle_audit_log ( @@ -33,7 +33,7 @@ class UserLifecycleAuditRepository return $id ? (int) $id : false; } - public static function updateStatus(int $id, string $status, ?string $reasonCode = null): bool + public function updateStatus(int $id, string $status, ?string $reasonCode = null): bool { if ($id <= 0) { return false; @@ -52,7 +52,7 @@ class UserLifecycleAuditRepository return $updated !== false; } - public static function listPaged(array $filters): array + public function listPaged(array $filters): array { $search = trim((string) ($filters['search'] ?? '')); $action = strtolower(trim((string) ($filters['action'] ?? ''))); @@ -147,7 +147,7 @@ class UserLifecycleAuditRepository $normalized = []; if (is_array($rows)) { foreach ($rows as $row) { - $item = self::normalizeRow($row, false); + $item = $this->normalizeRow($row, false); if ($item !== null) { $normalized[] = $item; } @@ -157,7 +157,7 @@ class UserLifecycleAuditRepository return ['total' => $total, 'rows' => $normalized]; } - public static function find(int $id): ?array + public function find(int $id): ?array { if ($id <= 0) { return null; @@ -201,10 +201,10 @@ class UserLifecycleAuditRepository (string) $id ); - return self::normalizeRow($row, true); + return $this->normalizeRow($row, true); } - public static function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array + public function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array { if ($id <= 0) { return null; @@ -233,7 +233,7 @@ class UserLifecycleAuditRepository return is_array($item) ? $item : null; } - public static function markRestored(int $id, int $restoredBy, int $restoredUserId): bool + public function markRestored(int $id, int $restoredBy, int $restoredUserId): bool { if ($id <= 0 || $restoredBy <= 0 || $restoredUserId <= 0) { return false; @@ -251,7 +251,7 @@ class UserLifecycleAuditRepository return (int) $updated > 0; } - public static function purgeOlderThanDays(int $days): int + public function purgeOlderThanDays(int $days): int { if ($days <= 0) { return 0; @@ -264,7 +264,7 @@ class UserLifecycleAuditRepository return is_int($deleted) ? $deleted : 0; } - private static function normalizeRow(mixed $row, bool $includeSnapshot): ?array + private function normalizeRow(mixed $row, bool $includeSnapshot): ?array { if (!is_array($row)) { return null; @@ -294,4 +294,3 @@ class UserLifecycleAuditRepository return $item; } } - diff --git a/lib/Repository/Auth/ApiTokenRepository.php b/lib/Repository/Auth/ApiTokenRepository.php index 0ac8632..7492ae5 100644 --- a/lib/Repository/Auth/ApiTokenRepository.php +++ b/lib/Repository/Auth/ApiTokenRepository.php @@ -9,7 +9,7 @@ class ApiTokenRepository { private const UUID_REGEX = '/^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i'; - private static function unwrapList($rows): array + private function unwrapList($rows): array { if (!is_array($rows)) { return []; @@ -24,12 +24,12 @@ class ApiTokenRepository return $list; } - public static function isUuid(string $value): bool + public function isUuid(string $value): bool { return (bool) preg_match(self::UUID_REGEX, trim($value)); } - public static function create( + public function create( int $userId, string $name, string $selector, @@ -52,7 +52,7 @@ class ApiTokenRepository return $id ? (int) $id : null; } - public static function findBySelector(string $selector): ?array + public function findBySelector(string $selector): ?array { $row = DB::selectOne( 'select id, uuid, user_id, name, selector, token_hash, tenant_id, last_used_at, last_ip, expires_at, revoked_at, created_by, created from user_api_tokens where selector = ? limit 1', @@ -64,7 +64,7 @@ class ApiTokenRepository return $row['user_api_tokens']; } - public static function find(int $id): ?array + public function find(int $id): ?array { $row = DB::selectOne( 'select id, uuid, user_id, name, selector, tenant_id, last_used_at, last_ip, expires_at, revoked_at, created_by, created from user_api_tokens where id = ? limit 1', @@ -76,10 +76,10 @@ class ApiTokenRepository return $row['user_api_tokens']; } - public static function findByUuid(string $uuid): ?array + public function findByUuid(string $uuid): ?array { $uuid = trim($uuid); - if (!self::isUuid($uuid)) { + if (!$this->isUuid($uuid)) { return null; } @@ -93,10 +93,10 @@ class ApiTokenRepository return $row['user_api_tokens']; } - public static function findByUuidForUser(string $uuid, int $userId): ?array + public function findByUuidForUser(string $uuid, int $userId): ?array { $uuid = trim($uuid); - if ($userId <= 0 || !self::isUuid($uuid)) { + if ($userId <= 0 || !$this->isUuid($uuid)) { return null; } @@ -111,7 +111,7 @@ class ApiTokenRepository return $row['user_api_tokens']; } - public static function updateLastUsed(int $id, string $ip): bool + public function updateLastUsed(int $id, string $ip): bool { $result = DB::update( 'update user_api_tokens set last_used_at = NOW(), last_ip = ? where id = ?', @@ -121,7 +121,7 @@ class ApiTokenRepository return $result !== false; } - public static function revoke(int $id): bool + public function revoke(int $id): bool { $result = DB::update( 'update user_api_tokens set revoked_at = NOW() where id = ? and revoked_at is null', @@ -130,10 +130,10 @@ class ApiTokenRepository return $result !== false; } - public static function revokeByUuidForUser(string $uuid, int $userId): bool + public function revokeByUuidForUser(string $uuid, int $userId): bool { $uuid = trim($uuid); - if ($userId <= 0 || !self::isUuid($uuid)) { + if ($userId <= 0 || !$this->isUuid($uuid)) { return false; } @@ -145,7 +145,7 @@ class ApiTokenRepository return $result !== false; } - public static function revokeAllForUser(int $userId, ?int $tenantId = null): int + public function revokeAllForUser(int $userId, ?int $tenantId = null): int { if ($tenantId !== null && $tenantId > 0) { $result = DB::update( @@ -162,7 +162,7 @@ class ApiTokenRepository return $result !== false ? (int) $result : 0; } - public static function revokeAllActiveByAdmin(): int + public function revokeAllActiveByAdmin(): int { $result = DB::update( 'update user_api_tokens set revoked_at = NOW() where revoked_at is null and (expires_at is null or expires_at > NOW())' @@ -170,7 +170,7 @@ class ApiTokenRepository return $result !== false ? (int) $result : 0; } - public static function listByUserId(int $userId, int $limit = 25): array + public function listByUserId(int $userId, int $limit = 25): array { if ($userId <= 0) { return []; @@ -185,10 +185,10 @@ class ApiTokenRepository (string) $userId, (string) $limit ); - return self::unwrapList($rows); + return $this->unwrapList($rows); } - public static function countActiveForUser(int $userId): int + public function countActiveForUser(int $userId): int { $count = DB::selectValue( 'select count(*) from user_api_tokens where user_id = ? and revoked_at is null and (expires_at is null or expires_at > NOW())', @@ -197,7 +197,7 @@ class ApiTokenRepository return $count ? (int) $count : 0; } - public static function countActiveForUserExcludingId(int $userId, int $excludeId): int + public function countActiveForUserExcludingId(int $userId, int $excludeId): int { if ($userId <= 0) { return 0; @@ -211,7 +211,7 @@ class ApiTokenRepository return $count ? (int) $count : 0; } - public static function countActive(): int + public function countActive(): int { $count = DB::selectValue( 'select count(*) from user_api_tokens where revoked_at is null and (expires_at is null or expires_at > NOW())' diff --git a/lib/Repository/Auth/EmailVerificationRepository.php b/lib/Repository/Auth/EmailVerificationRepository.php index 37b990d..9380a30 100644 --- a/lib/Repository/Auth/EmailVerificationRepository.php +++ b/lib/Repository/Auth/EmailVerificationRepository.php @@ -6,7 +6,7 @@ use MintyPHP\DB; class EmailVerificationRepository { - public static function create(int $userId, string $codeHash, string $expiresAt): ?int + public function create(int $userId, string $codeHash, string $expiresAt): ?int { $id = DB::insert( 'insert into email_verifications (user_id, code_hash, expires_at, attempts, used_at, created) values (?,?,?,?,NULL,NOW())', @@ -18,7 +18,7 @@ class EmailVerificationRepository return $id ? (int) $id : null; } - public static function invalidateForUser(int $userId): bool + public function invalidateForUser(int $userId): bool { $result = DB::update( 'update email_verifications set used_at = NOW() where user_id = ? and used_at is null', @@ -27,7 +27,7 @@ class EmailVerificationRepository return $result !== false; } - public static function findActiveByUserId(int $userId): ?array + public function findActiveByUserId(int $userId): ?array { $row = DB::selectOne( 'select id, user_id, code_hash, expires_at, attempts, used_at from email_verifications where user_id = ? and used_at is null and expires_at > UTC_TIMESTAMP() order by id desc limit 1', @@ -39,7 +39,7 @@ class EmailVerificationRepository return $row['email_verifications']; } - public static function findById(int $id): ?array + public function findById(int $id): ?array { $row = DB::selectOne( 'select id, user_id, code_hash, expires_at, attempts, used_at from email_verifications where id = ? limit 1', @@ -51,7 +51,7 @@ class EmailVerificationRepository return $row['email_verifications']; } - public static function incrementAttempts(int $id): bool + public function incrementAttempts(int $id): bool { $result = DB::update( 'update email_verifications set attempts = attempts + 1 where id = ?', @@ -60,7 +60,7 @@ class EmailVerificationRepository return $result !== false; } - public static function markUsed(int $id): bool + public function markUsed(int $id): bool { $result = DB::update( 'update email_verifications set used_at = NOW() where id = ?', diff --git a/lib/Repository/Auth/PasswordResetRepository.php b/lib/Repository/Auth/PasswordResetRepository.php index af8d634..73509ba 100644 --- a/lib/Repository/Auth/PasswordResetRepository.php +++ b/lib/Repository/Auth/PasswordResetRepository.php @@ -6,7 +6,7 @@ use MintyPHP\DB; class PasswordResetRepository { - private static function unwrapList($rows): array + private function unwrapList($rows): array { if (!is_array($rows)) { return []; @@ -21,7 +21,7 @@ class PasswordResetRepository return $list; } - public static function create(int $userId, string $codeHash, string $expiresAt): ?int + public function create(int $userId, string $codeHash, string $expiresAt): ?int { $id = DB::insert( 'insert into password_resets (user_id, code_hash, expires_at, attempts, used_at, created) values (?,?,?,?,NULL,NOW())', @@ -33,7 +33,7 @@ class PasswordResetRepository return $id ? (int) $id : null; } - public static function invalidateForUser(int $userId): bool + public function invalidateForUser(int $userId): bool { $result = DB::update( 'update password_resets set used_at = NOW() where user_id = ? and used_at is null', @@ -42,7 +42,7 @@ class PasswordResetRepository return $result !== false; } - public static function findActiveByUserId(int $userId): ?array + public function findActiveByUserId(int $userId): ?array { $row = DB::selectOne( 'select id, user_id, code_hash, expires_at, attempts, used_at from password_resets where user_id = ? and used_at is null and expires_at > UTC_TIMESTAMP() order by id desc limit 1', @@ -54,7 +54,7 @@ class PasswordResetRepository return $row['password_resets']; } - public static function findById(int $id): ?array + public function findById(int $id): ?array { $row = DB::selectOne( 'select id, user_id, code_hash, expires_at, attempts, used_at from password_resets where id = ? limit 1', @@ -66,7 +66,7 @@ class PasswordResetRepository return $row['password_resets']; } - public static function incrementAttempts(int $id): bool + public function incrementAttempts(int $id): bool { $result = DB::update( 'update password_resets set attempts = attempts + 1 where id = ?', @@ -75,7 +75,7 @@ class PasswordResetRepository return $result !== false; } - public static function markUsed(int $id): bool + public function markUsed(int $id): bool { $result = DB::update( 'update password_resets set used_at = NOW() where id = ?', @@ -84,7 +84,7 @@ class PasswordResetRepository return $result !== false; } - public static function listByUserId(int $userId, int $limit = 20): array + public function listByUserId(int $userId, int $limit = 20): array { if ($userId <= 0) { return []; @@ -99,6 +99,6 @@ class PasswordResetRepository (string) $userId, (string) $limit ); - return self::unwrapList($rows); + return $this->unwrapList($rows); } } diff --git a/lib/Repository/Auth/RememberTokenRepository.php b/lib/Repository/Auth/RememberTokenRepository.php index ad895e7..760ed07 100644 --- a/lib/Repository/Auth/RememberTokenRepository.php +++ b/lib/Repository/Auth/RememberTokenRepository.php @@ -6,7 +6,7 @@ use MintyPHP\DB; class RememberTokenRepository { - private static function unwrapList($rows): array + private function unwrapList($rows): array { if (!is_array($rows)) { return []; @@ -21,7 +21,7 @@ class RememberTokenRepository return $list; } - public static function create(int $userId, string $selector, string $tokenHash, string $expiresAt): ?int + public function create(int $userId, string $selector, string $tokenHash, string $expiresAt): ?int { $id = DB::insert( 'insert into user_remember_tokens (user_id, selector, token_hash, expires_at, created) values (?,?,?,?,NOW())', @@ -33,7 +33,7 @@ class RememberTokenRepository return $id ? (int) $id : null; } - public static function findBySelector(string $selector): ?array + public function findBySelector(string $selector): ?array { $row = DB::selectOne( 'select id, user_id, selector, token_hash, expires_at, expired_by_admin_at, last_used from user_remember_tokens where selector = ? limit 1', @@ -45,7 +45,7 @@ class RememberTokenRepository return $row['user_remember_tokens']; } - public static function updateToken(int $id, string $tokenHash, string $expiresAt): bool + public function updateToken(int $id, string $tokenHash, string $expiresAt): bool { $result = DB::update( 'update user_remember_tokens set token_hash = ?, expires_at = ?, expired_by_admin_at = NULL, last_used = NOW() where id = ?', @@ -56,7 +56,7 @@ class RememberTokenRepository return $result !== false; } - public static function expireAllByAdmin(): int + public function expireAllByAdmin(): int { $result = DB::update( 'update user_remember_tokens set expires_at = NOW(), expired_by_admin_at = NOW() where expires_at > NOW()' @@ -64,19 +64,19 @@ class RememberTokenRepository return $result !== false ? (int) $result : 0; } - public static function deleteById(int $id): bool + public function deleteById(int $id): bool { $result = DB::delete('delete from user_remember_tokens where id = ?', (string) $id); return $result !== false; } - public static function deleteByUserId(int $userId): bool + public function deleteByUserId(int $userId): bool { $result = DB::delete('delete from user_remember_tokens where user_id = ?', (string) $userId); return $result !== false; } - public static function listByUserId(int $userId, int $limit = 20): array + public function listByUserId(int $userId, int $limit = 20): array { if ($userId <= 0) { return []; @@ -91,10 +91,10 @@ class RememberTokenRepository (string) $userId, (string) $limit ); - return self::unwrapList($rows); + return $this->unwrapList($rows); } - public static function countActive(): int + public function countActive(): int { $count = DB::selectValue('select count(*) from user_remember_tokens where expires_at > NOW()'); return $count ? (int) $count : 0; diff --git a/lib/Repository/Mail/MailLogRepository.php b/lib/Repository/Mail/MailLogRepository.php index 4ce9b40..24b64e0 100644 --- a/lib/Repository/Mail/MailLogRepository.php +++ b/lib/Repository/Mail/MailLogRepository.php @@ -7,7 +7,7 @@ use MintyPHP\Repository\Support\RepoQuery; class MailLogRepository { - public static function create(array $data): ?int + public function create(array $data): ?int { $id = DB::insert( 'insert into mail_log (to_email, subject, template, status, created_at) values (?,?,?,?,NOW())', @@ -19,7 +19,7 @@ class MailLogRepository return $id ? (int) $id : null; } - public static function markSent(int $id, ?string $providerMessageId = null): bool + public function markSent(int $id, ?string $providerMessageId = null): bool { $result = DB::update( 'update mail_log set status = ?, sent_at = NOW(), provider_message_id = ?, error_message = NULL where id = ?', @@ -30,7 +30,7 @@ class MailLogRepository return $result !== false; } - public static function markFailed(int $id, string $errorMessage): bool + public function markFailed(int $id, string $errorMessage): bool { $result = DB::update( 'update mail_log set status = ?, error_message = ? where id = ?', @@ -41,30 +41,7 @@ class MailLogRepository return $result !== false; } - private static function unwrap(?array $row): ?array - { - if (!$row) { - return null; - } - return $row['mail_log'] ?? null; - } - - private static function unwrapList($rows): array - { - if (!is_array($rows)) { - return []; - } - $list = []; - foreach ($rows as $row) { - $mailLog = $row['mail_log'] ?? null; - if (is_array($mailLog)) { - $list[] = $mailLog; - } - } - return $list; - } - - public static function listPaged(array $options): array + public function listPaged(array $options): array { $search = trim((string) ($options['search'] ?? '')); $status = trim((string) ($options['status'] ?? '')); @@ -101,16 +78,41 @@ class MailLogRepository return [ 'total' => $total, - 'rows' => self::unwrapList($rows), + 'rows' => $this->unwrapList($rows), ]; } - public static function find(int $id): ?array + public function find(int $id): ?array { $row = DB::selectOne( 'select id, to_email, subject, template, status, created_at, sent_at, error_message, provider_message_id from mail_log where id = ? limit 1', (string) $id ); - return self::unwrap($row); + return $this->unwrap($row); + } + + private function unwrap(?array $row): ?array + { + if (!$row) { + return null; + } + return $row['mail_log'] ?? null; + } + + private function unwrapList(mixed $rows): array + { + if (!is_array($rows)) { + return []; + } + + $list = []; + foreach ($rows as $row) { + $mailLog = $row['mail_log'] ?? null; + if (is_array($mailLog)) { + $list[] = $mailLog; + } + } + + return $list; } } diff --git a/lib/Repository/Org/DepartmentRepository.php b/lib/Repository/Org/DepartmentRepository.php index 91b3264..47aed4f 100644 --- a/lib/Repository/Org/DepartmentRepository.php +++ b/lib/Repository/Org/DepartmentRepository.php @@ -7,7 +7,7 @@ use MintyPHP\Repository\Support\RepoQuery; class DepartmentRepository { - private static function unwrap(?array $row): ?array + private function unwrap(?array $row): ?array { if (!$row) { return null; @@ -15,7 +15,7 @@ class DepartmentRepository return $row['departments'] ?? null; } - private static function unwrapList($rows): array + private function unwrapList($rows): array { if (!is_array($rows)) { return []; @@ -30,15 +30,15 @@ class DepartmentRepository return $list; } - public static function list(): array + public function list(): array { $rows = DB::select( 'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments order by id desc' ); - return self::unwrapList($rows); + return $this->unwrapList($rows); } - public static function listIds(): array + public function listIds(): array { $rows = DB::select('select id from departments'); if (!is_array($rows)) { @@ -54,7 +54,7 @@ class DepartmentRepository return array_values(array_unique($ids)); } - public static function listActiveIdsByTenantIds(array $tenantIds): array + public function listActiveIdsByTenantIds(array $tenantIds): array { $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); $tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0); @@ -79,7 +79,7 @@ class DepartmentRepository return array_values(array_unique($ids)); } - public static function listByTenantIds(array $tenantIds): array + public function listByTenantIds(array $tenantIds): array { $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); $tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0); @@ -94,10 +94,10 @@ class DepartmentRepository 'order by departments.description asc', ...array_map('strval', $tenantIds) ); - return self::unwrapList($rows); + return $this->unwrapList($rows); } - public static function listByIds(array $departmentIds, bool $includeInactive = false): array + public function listByIds(array $departmentIds, bool $includeInactive = false): array { $departmentIds = array_values(array_unique(array_map('intval', $departmentIds))); $departmentIds = array_filter($departmentIds, static fn ($id) => $id > 0); @@ -110,10 +110,10 @@ class DepartmentRepository 'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments where ' . $activeSql . 'id in (' . $placeholders . ') order by description asc', ...array_map('strval', $departmentIds) ); - return self::unwrapList($rows); + return $this->unwrapList($rows); } - public static function listPaged(array $options): array + public function listPaged(array $options): array { $search = trim((string) ($options['search'] ?? '')); $tenant = trim((string) ($options['tenant'] ?? '')); @@ -171,7 +171,7 @@ class DepartmentRepository $queryParams = array_merge($params, [(string) $limit, (string) $offset]); $rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams)); - $list = self::unwrapList($rows); + $list = $this->unwrapList($rows); $ids = []; foreach ($list as $department) { if (isset($department['id'])) { @@ -217,25 +217,25 @@ class DepartmentRepository ]; } - public static function find(int $id): ?array + public function find(int $id): ?array { $row = DB::selectOne( 'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments where id = ? limit 1', (string) $id ); - return self::unwrap($row); + return $this->unwrap($row); } - public static function findByUuid(string $uuid): ?array + public function findByUuid(string $uuid): ?array { $row = DB::selectOne( 'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments where uuid = ? limit 1', $uuid ); - return self::unwrap($row); + return $this->unwrap($row); } - public static function existsByCode(string $code, int $excludeId = 0): bool + public function existsByCode(string $code, int $excludeId = 0): bool { $code = trim($code); if ($code === '') { @@ -249,7 +249,7 @@ class DepartmentRepository return (int) $count > 0; } - public static function existsByTenantAndDescription(int $tenantId, string $description, int $excludeId = 0): bool + public function existsByTenantAndDescription(int $tenantId, string $description, int $excludeId = 0): bool { $tenantId = (int) $tenantId; $description = trim($description); @@ -275,7 +275,7 @@ class DepartmentRepository return (int) $count > 0; } - public static function countByTenantId(int $tenantId): int + public function countByTenantId(int $tenantId): int { if ($tenantId <= 0) { return 0; @@ -284,7 +284,7 @@ class DepartmentRepository return $count ? (int) $count : 0; } - public static function create(array $data) + public function create(array $data) { return DB::insert( 'insert into departments (uuid, description, tenant_id, code, cost_center, active, created_by, created) values (?,?,?,?,?,?,?,NOW())', @@ -298,7 +298,7 @@ class DepartmentRepository ); } - public static function update(int $id, array $data): bool + public function update(int $id, array $data): bool { $fields = [ 'description' => $data['description'], @@ -324,7 +324,7 @@ class DepartmentRepository return $result !== false; } - public static function setTenant(int $id, int $tenantId): bool + public function setTenant(int $id, int $tenantId): bool { if ($id <= 0 || $tenantId <= 0) { return false; @@ -337,7 +337,7 @@ class DepartmentRepository return $result !== false; } - public static function delete(int $id): bool + public function delete(int $id): bool { $result = DB::delete('delete from departments where id = ?', (string) $id); return $result !== false; diff --git a/lib/Repository/Org/UserDepartmentRepository.php b/lib/Repository/Org/UserDepartmentRepository.php index 01f9ec5..9820efd 100644 --- a/lib/Repository/Org/UserDepartmentRepository.php +++ b/lib/Repository/Org/UserDepartmentRepository.php @@ -6,7 +6,7 @@ use MintyPHP\DB; class UserDepartmentRepository { - public static function listDepartmentIdsByUserId(int $userId): array + public function listDepartmentIdsByUserId(int $userId): array { $rows = DB::select('select department_id from user_departments where user_id = ?', (string) $userId); if (!is_array($rows)) { @@ -22,7 +22,7 @@ class UserDepartmentRepository return array_values(array_unique($ids)); } - public static function replaceForUser(int $userId, array $departmentIds): bool + public function replaceForUser(int $userId, array $departmentIds): bool { DB::delete('delete from user_departments where user_id = ?', (string) $userId); if (!$departmentIds) { @@ -40,7 +40,7 @@ class UserDepartmentRepository return true; } - public static function removeInvalidForDepartment(int $departmentId): int + public function removeInvalidForDepartment(int $departmentId): int { $query = 'delete ud from user_departments ud ' . 'where ud.department_id = ? and not exists (' . @@ -52,17 +52,17 @@ class UserDepartmentRepository return $result !== false ? (int) $result : 0; } - public static function countUsersByDepartmentIds(array $departmentIds): array + public function countUsersByDepartmentIds(array $departmentIds): array { - return self::countByDepartmentIds($departmentIds, false); + return $this->countByDepartmentIds($departmentIds, false); } - public static function countActiveUsersByDepartmentIds(array $departmentIds): array + public function countActiveUsersByDepartmentIds(array $departmentIds): array { - return self::countByDepartmentIds($departmentIds, true); + return $this->countByDepartmentIds($departmentIds, true); } - private static function countByDepartmentIds(array $departmentIds, bool $activeOnly): array + private function countByDepartmentIds(array $departmentIds, bool $activeOnly): array { $departmentIds = array_values(array_unique(array_map('intval', $departmentIds))); $departmentIds = array_values(array_filter($departmentIds, static fn ($id) => $id > 0)); @@ -85,17 +85,17 @@ class UserDepartmentRepository $result = []; foreach ($rows as $row) { - $departmentId = self::extractIntField($row, 'department_id'); + $departmentId = $this->extractIntField($row, 'department_id'); if ($departmentId <= 0) { continue; } - $result[$departmentId] = self::extractIntField($row, 'user_count'); + $result[$departmentId] = $this->extractIntField($row, 'user_count'); } return $result; } - private static function extractIntField(array $row, string $field): int + private function extractIntField(array $row, string $field): int { foreach ($row as $value) { if (!is_array($value)) { diff --git a/lib/Repository/Scheduler/ScheduledJobRepository.php b/lib/Repository/Scheduler/ScheduledJobRepository.php index ba18044..19d0829 100644 --- a/lib/Repository/Scheduler/ScheduledJobRepository.php +++ b/lib/Repository/Scheduler/ScheduledJobRepository.php @@ -7,7 +7,7 @@ use MintyPHP\Repository\Support\RepoQuery; class ScheduledJobRepository { - public static function create(array $data): int|false + public function create(array $data): int|false { $id = DB::insert( 'insert into scheduled_jobs ( @@ -30,26 +30,26 @@ class ScheduledJobRepository return $id ? (int) $id : false; } - public static function find(int $id): ?array + public function find(int $id): ?array { if ($id <= 0) { return null; } $row = DB::selectOne('select * from scheduled_jobs where id = ? limit 1', (string) $id); - return self::normalizeRow($row); + return $this->normalizeRow($row); } - public static function findByKey(string $jobKey): ?array + public function findByKey(string $jobKey): ?array { $jobKey = trim($jobKey); if ($jobKey === '') { return null; } $row = DB::selectOne('select * from scheduled_jobs where job_key = ? limit 1', $jobKey); - return self::normalizeRow($row); + return $this->normalizeRow($row); } - public static function listPaged(array $filters): array + public function listPaged(array $filters): array { $search = trim((string) ($filters['search'] ?? '')); $enabled = trim((string) ($filters['enabled'] ?? '')); @@ -92,7 +92,7 @@ class ScheduledJobRepository $normalized = []; if (is_array($rows)) { foreach ($rows as $row) { - $item = self::normalizeRow($row); + $item = $this->normalizeRow($row); if ($item !== null) { $normalized[] = $item; } @@ -105,7 +105,7 @@ class ScheduledJobRepository ]; } - public static function listDueJobs(string $nowUtc, int $limit = 20): array + public function listDueJobs(string $nowUtc, int $limit = 20): array { $limit = max(1, min(200, $limit)); $rows = DB::select( @@ -122,7 +122,7 @@ class ScheduledJobRepository $normalized = []; if (is_array($rows)) { foreach ($rows as $row) { - $item = self::normalizeRow($row); + $item = $this->normalizeRow($row); if ($item !== null) { $normalized[] = $item; } @@ -131,7 +131,7 @@ class ScheduledJobRepository return $normalized; } - public static function updateJobMeta(int $id, array $data): bool + public function updateJobMeta(int $id, array $data): bool { if ($id <= 0) { return false; @@ -176,7 +176,7 @@ class ScheduledJobRepository * Returns true only when the UPDATE affected a row, i.e. the job was successfully * claimed by this runner. Returns false if the job is already running (not stale). */ - public static function markRunning(int $id, string $startedAtUtc): bool + public function markRunning(int $id, string $startedAtUtc): bool { if ($id <= 0) { return false; @@ -206,7 +206,7 @@ class ScheduledJobRepository return (int) $updated > 0; } - public static function finishRun( + public function finishRun( int $id, string $status, string $startedAtUtc, @@ -238,7 +238,7 @@ class ScheduledJobRepository return $updated !== false; } - private static function normalizeRow(mixed $row): ?array + private function normalizeRow(mixed $row): ?array { if (!is_array($row)) { return null; diff --git a/lib/Repository/Scheduler/ScheduledJobRunRepository.php b/lib/Repository/Scheduler/ScheduledJobRunRepository.php index 1346543..c7c5192 100644 --- a/lib/Repository/Scheduler/ScheduledJobRunRepository.php +++ b/lib/Repository/Scheduler/ScheduledJobRunRepository.php @@ -7,7 +7,7 @@ use MintyPHP\Repository\Support\RepoQuery; class ScheduledJobRunRepository { - public static function create(array $data): int|false + public function create(array $data): int|false { $id = DB::insert( 'insert into scheduled_job_runs ( @@ -30,7 +30,7 @@ class ScheduledJobRunRepository return $id ? (int) $id : false; } - public static function listPagedByJobId(int $jobId, array $filters): array + public function listPagedByJobId(int $jobId, array $filters): array { if ($jobId <= 0) { return ['total' => 0, 'rows' => []]; @@ -97,7 +97,7 @@ class ScheduledJobRunRepository $normalized = []; if (is_array($rows)) { foreach ($rows as $row) { - $item = self::normalizeRow($row); + $item = $this->normalizeRow($row); if ($item !== null) { $normalized[] = $item; } @@ -107,7 +107,7 @@ class ScheduledJobRunRepository return ['total' => $total, 'rows' => $normalized]; } - public static function purgeOlderThanDays(int $days): int + public function purgeOlderThanDays(int $days): int { if ($days <= 0) { return 0; @@ -119,7 +119,7 @@ class ScheduledJobRunRepository return is_int($deleted) ? $deleted : 0; } - private static function normalizeRow(mixed $row): ?array + private function normalizeRow(mixed $row): ?array { if (!is_array($row)) { return null; diff --git a/lib/Repository/Scheduler/SchedulerRuntimeRepository.php b/lib/Repository/Scheduler/SchedulerRuntimeRepository.php index c96ecef..2efdd64 100644 --- a/lib/Repository/Scheduler/SchedulerRuntimeRepository.php +++ b/lib/Repository/Scheduler/SchedulerRuntimeRepository.php @@ -6,7 +6,7 @@ use MintyPHP\DB; class SchedulerRuntimeRepository { - public static function touchHeartbeat(string $result, ?string $errorCode = null, ?string $heartbeatAtUtc = null): bool + public function touchHeartbeat(string $result, ?string $errorCode = null, ?string $heartbeatAtUtc = null): bool { $heartbeatAtUtc = trim((string) ($heartbeatAtUtc ?? '')); if ($heartbeatAtUtc === '') { @@ -17,7 +17,7 @@ class SchedulerRuntimeRepository if ($result === '') { $result = 'ok'; } - $errorCode = self::nullableTrim($errorCode); + $errorCode = $this->nullableTrim($errorCode); $updated = DB::update( 'insert into scheduler_runtime_status (id, last_heartbeat_at, last_result, last_error_code) ' . @@ -34,7 +34,7 @@ class SchedulerRuntimeRepository return $updated !== false; } - public static function findStatus(): ?array + public function findStatus(): ?array { $row = DB::selectOne('select * from scheduler_runtime_status where id = 1 limit 1'); if (!is_array($row)) { @@ -44,7 +44,7 @@ class SchedulerRuntimeRepository return is_array($item) ? $item : null; } - private static function nullableTrim(?string $value): ?string + private function nullableTrim(?string $value): ?string { $value = trim((string) $value); return $value !== '' ? $value : null; diff --git a/lib/Repository/Security/RateLimitRepository.php b/lib/Repository/Security/RateLimitRepository.php index e1c4692..4497011 100644 --- a/lib/Repository/Security/RateLimitRepository.php +++ b/lib/Repository/Security/RateLimitRepository.php @@ -6,7 +6,7 @@ use MintyPHP\DB; class RateLimitRepository { - public static function findByScopeAndHash(string $scope, string $subjectHash): ?array + public function findByScopeAndHash(string $scope, string $subjectHash): ?array { $row = DB::selectOne( 'select id, scope, subject_hash, hits, window_started_at, blocked_until, created, modified from request_rate_limits where scope = ? and subject_hash = ? limit 1', @@ -19,7 +19,7 @@ class RateLimitRepository return $row['request_rate_limits']; } - public static function create( + public function create( string $scope, string $subjectHash, int $hits, @@ -37,7 +37,7 @@ class RateLimitRepository return $result !== false; } - public static function updateStateById( + public function updateStateById( int $id, int $hits, string $windowStartedAt, @@ -57,7 +57,7 @@ class RateLimitRepository return $result !== false; } - public static function deleteByScopeAndHash(string $scope, string $subjectHash): bool + public function deleteByScopeAndHash(string $scope, string $subjectHash): bool { $result = DB::delete( 'delete from request_rate_limits where scope = ? and subject_hash = ?', diff --git a/lib/Repository/Settings/SettingRepository.php b/lib/Repository/Settings/SettingRepository.php index 19c9976..67572c9 100644 --- a/lib/Repository/Settings/SettingRepository.php +++ b/lib/Repository/Settings/SettingRepository.php @@ -6,7 +6,7 @@ use MintyPHP\DB; class SettingRepository { - public static function find(string $key): ?array + public function find(string $key): ?array { $row = DB::selectOne('select `key`, `value`, `description`, `updated_at` from settings where `key` = ? limit 1', $key); if (!$row) { @@ -15,16 +15,16 @@ class SettingRepository return $row['settings'] ?? $row; } - public static function getValue(string $key): ?string + public function getValue(string $key): ?string { - $row = self::find($key); + $row = $this->find($key); if (!$row) { return null; } return array_key_exists('value', $row) ? (string) $row['value'] : null; } - public static function set(string $key, ?string $value, ?string $description = null): bool + public function set(string $key, ?string $value, ?string $description = null): bool { $query = 'insert into settings (`key`, `value`, `description`) values (?, ?, ?) ' . 'on duplicate key update `value` = values(`value`), ' diff --git a/lib/Repository/Tenant/TenantMicrosoftAuthRepository.php b/lib/Repository/Tenant/TenantMicrosoftAuthRepository.php index f7dd209..28f9457 100644 --- a/lib/Repository/Tenant/TenantMicrosoftAuthRepository.php +++ b/lib/Repository/Tenant/TenantMicrosoftAuthRepository.php @@ -6,7 +6,7 @@ use MintyPHP\DB; class TenantMicrosoftAuthRepository { - private static function unwrap($row): ?array + private function unwrap($row): ?array { if (!$row || !is_array($row)) { return null; @@ -17,16 +17,16 @@ class TenantMicrosoftAuthRepository return $row; } - public static function findByTenantId(int $tenantId): ?array + public function findByTenantId(int $tenantId): ?array { $row = DB::selectOne( 'select id, tenant_id, enabled, enforce_microsoft_login, sync_profile_on_login, sync_profile_fields, entra_tenant_id, allowed_domains, use_shared_app, client_id_override, client_secret_override_enc, created, modified from tenant_auth_microsoft where tenant_id = ? limit 1', (string) $tenantId ); - return self::unwrap($row); + return $this->unwrap($row); } - public static function listByTenantIds(array $tenantIds): array + public function listByTenantIds(array $tenantIds): array { $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); $tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0)); @@ -46,7 +46,7 @@ class TenantMicrosoftAuthRepository $result = []; foreach ($rows as $row) { - $item = self::unwrap($row); + $item = $this->unwrap($row); if (!is_array($item)) { continue; } @@ -60,7 +60,7 @@ class TenantMicrosoftAuthRepository return $result; } - public static function upsertByTenantId(int $tenantId, array $data): bool + public function upsertByTenantId(int $tenantId, array $data): bool { $result = DB::update( 'insert into tenant_auth_microsoft (tenant_id, enabled, enforce_microsoft_login, sync_profile_on_login, sync_profile_fields, entra_tenant_id, allowed_domains, use_shared_app, client_id_override, client_secret_override_enc, created) values (?,?,?,?,?,?,?,?,?,?,NOW()) ' . @@ -81,5 +81,4 @@ class TenantMicrosoftAuthRepository return $result !== false; } - } diff --git a/lib/Repository/Tenant/TenantRepository.php b/lib/Repository/Tenant/TenantRepository.php index 1162d69..9894f9f 100644 --- a/lib/Repository/Tenant/TenantRepository.php +++ b/lib/Repository/Tenant/TenantRepository.php @@ -7,7 +7,7 @@ use MintyPHP\Repository\Support\RepoQuery; class TenantRepository { - private static function unwrap(?array $row): ?array + private function unwrap(?array $row): ?array { if (!$row) { return null; @@ -15,7 +15,7 @@ class TenantRepository return $row['tenants'] ?? null; } - private static function unwrapList($rows): array + private function unwrapList($rows): array { if (!is_array($rows)) { return []; @@ -30,15 +30,15 @@ class TenantRepository return $list; } - public static function list(): array + public function list(): array { $rows = DB::select( 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants order by id desc' ); - return self::unwrapList($rows); + return $this->unwrapList($rows); } - public static function listIds(): array + public function listIds(): array { $rows = DB::select('select id from tenants'); if (!is_array($rows)) { @@ -54,7 +54,7 @@ class TenantRepository return array_values(array_unique($ids)); } - public static function listByIds(array $tenantIds): array + public function listByIds(array $tenantIds): array { $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); $tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0)); @@ -67,10 +67,10 @@ class TenantRepository 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where id in (' . $placeholders . ')', ...array_map('strval', $tenantIds) ); - return self::unwrapList($rows); + return $this->unwrapList($rows); } - public static function listActiveIdsByIds(array $tenantIds): array + public function listActiveIdsByIds(array $tenantIds): array { $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); if (!$tenantIds) { @@ -98,7 +98,7 @@ class TenantRepository return array_values(array_unique($ids)); } - public static function listPaged(array $options): array + public function listPaged(array $options): array { $search = trim((string) ($options['search'] ?? '')); $allowedOrder = ['id', 'uuid', 'description', 'status', 'created', 'modified']; @@ -140,29 +140,29 @@ class TenantRepository return [ 'total' => $total, - 'rows' => self::unwrapList($rows), + 'rows' => $this->unwrapList($rows), ]; } - public static function find(int $id): ?array + public function find(int $id): ?array { $row = DB::selectOne( 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where id = ? limit 1', (string) $id ); - return self::unwrap($row); + return $this->unwrap($row); } - public static function findByUuid(string $uuid): ?array + public function findByUuid(string $uuid): ?array { $row = DB::selectOne( 'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where uuid = ? limit 1', $uuid ); - return self::unwrap($row); + return $this->unwrap($row); } - public static function create(array $data) + public function create(array $data) { return DB::insert( 'insert into tenants (uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, created) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())', @@ -194,7 +194,7 @@ class TenantRepository ); } - public static function update(int $id, array $data): bool + public function update(int $id, array $data): bool { $fields = [ 'description' => $data['description'], @@ -242,7 +242,7 @@ class TenantRepository return $result !== false; } - public static function delete(int $id): bool + public function delete(int $id): bool { $result = DB::delete('delete from tenants where id = ?', (string) $id); return $result !== false; diff --git a/lib/Repository/Tenant/UserTenantRepository.php b/lib/Repository/Tenant/UserTenantRepository.php index 84b46eb..376ba9c 100644 --- a/lib/Repository/Tenant/UserTenantRepository.php +++ b/lib/Repository/Tenant/UserTenantRepository.php @@ -6,7 +6,7 @@ use MintyPHP\DB; class UserTenantRepository { - public static function listTenantIdsByUserId(int $userId): array + public function listTenantIdsByUserId(int $userId): array { $rows = DB::select('select tenant_id from user_tenants where user_id = ?', (string) $userId); if (!is_array($rows)) { @@ -22,7 +22,7 @@ class UserTenantRepository return array_values(array_unique($ids)); } - public static function replaceForUser(int $userId, array $tenantIds): bool + public function replaceForUser(int $userId, array $tenantIds): bool { DB::delete('delete from user_tenants where user_id = ?', (string) $userId); if (!$tenantIds) { @@ -40,17 +40,17 @@ class UserTenantRepository return true; } - public static function countUsersByTenantIds(array $tenantIds): array + public function countUsersByTenantIds(array $tenantIds): array { - return self::countByTenantIds($tenantIds, false); + return $this->countByTenantIds($tenantIds, false); } - public static function countActiveUsersByTenantIds(array $tenantIds): array + public function countActiveUsersByTenantIds(array $tenantIds): array { - return self::countByTenantIds($tenantIds, true); + return $this->countByTenantIds($tenantIds, true); } - private static function countByTenantIds(array $tenantIds, bool $activeOnly): array + private function countByTenantIds(array $tenantIds, bool $activeOnly): array { $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); $tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0)); @@ -74,17 +74,17 @@ class UserTenantRepository $result = []; foreach ($rows as $row) { - $tenantId = self::extractIntField($row, 'tenant_id'); + $tenantId = $this->extractIntField($row, 'tenant_id'); if ($tenantId <= 0) { continue; } - $result[$tenantId] = self::extractIntField($row, 'user_count'); + $result[$tenantId] = $this->extractIntField($row, 'user_count'); } return $result; } - private static function extractIntField(array $row, string $field): int + private function extractIntField(array $row, string $field): int { foreach ($row as $value) { if (!is_array($value)) { diff --git a/lib/Repository/User/UserExternalIdentityRepository.php b/lib/Repository/User/UserExternalIdentityRepository.php index 5981441..488b78d 100644 --- a/lib/Repository/User/UserExternalIdentityRepository.php +++ b/lib/Repository/User/UserExternalIdentityRepository.php @@ -6,7 +6,7 @@ use MintyPHP\DB; class UserExternalIdentityRepository { - private static function unwrap($row): ?array + private function unwrap($row): ?array { if (!$row || !is_array($row)) { return null; @@ -17,7 +17,7 @@ class UserExternalIdentityRepository return $row; } - public static function findByProviderTidOid(string $provider, string $tid, string $oid): ?array + public function findByProviderTidOid(string $provider, string $tid, string $oid): ?array { $row = DB::selectOne( 'select id, user_id, provider, oid, tid, issuer, subject, email_at_link_time, created from user_external_identities where provider = ? and tid = ? and oid = ? limit 1', @@ -25,10 +25,10 @@ class UserExternalIdentityRepository $tid, $oid ); - return self::unwrap($row); + return $this->unwrap($row); } - public static function findByProviderIssuerSubject(string $provider, string $issuer, string $subject): ?array + public function findByProviderIssuerSubject(string $provider, string $issuer, string $subject): ?array { $row = DB::selectOne( 'select id, user_id, provider, oid, tid, issuer, subject, email_at_link_time, created from user_external_identities where provider = ? and issuer = ? and subject = ? limit 1', @@ -36,10 +36,10 @@ class UserExternalIdentityRepository $issuer, $subject ); - return self::unwrap($row); + return $this->unwrap($row); } - public static function createLink(array $data) + public function createLink(array $data) { return DB::insert( 'insert into user_external_identities (user_id, provider, oid, tid, issuer, subject, email_at_link_time, created) values (?,?,?,?,?,?,?,NOW())', diff --git a/lib/Repository/User/UserListQueryRepository.php b/lib/Repository/User/UserListQueryRepository.php new file mode 100644 index 0000000..a3fc728 --- /dev/null +++ b/lib/Repository/User/UserListQueryRepository.php @@ -0,0 +1,384 @@ + ['1', 'true', 'active'], 'sql' => 'users.active = ?', 'params' => ['1']], + ['aliases' => ['0', 'false', 'inactive'], 'sql' => 'users.active = ?', 'params' => ['0']], + ]); + + if ($createdFrom !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) { + $where[] = 'users.created >= ?'; + $params[] = $createdFrom . ' 00:00:00'; + } + if ($createdTo !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) { + $where[] = 'users.created <= ?'; + $params[] = $createdTo . ' 23:59:59'; + } + if ($tenantUuids) { + $where[] = 'exists (select 1 from user_tenants ut2 join tenants t2 on t2.id = ut2.tenant_id where ut2.user_id = users.id and t2.uuid in (???))'; + $params[] = array_values($tenantUuids); + } else { + RepoQuery::addEqualsFilter( + $where, + $params, + $tenant, + 'exists (select 1 from user_tenants ut2 join tenants t2 on t2.id = ut2.tenant_id where ut2.user_id = users.id and t2.uuid = ?)' + ); + } + if ($roleIds) { + $where[] = 'exists (select 1 from user_roles ur2 where ur2.user_id = users.id and ur2.role_id in (???))'; + $params[] = array_map('strval', $roleIds); + } + if ($departmentIds) { + $where[] = 'exists (select 1 from user_departments ud2 where ud2.user_id = users.id and ud2.department_id in (???))'; + $params[] = array_map('strval', $departmentIds); + } + RepoQuery::addEnumFilter($where, $params, $emailVerified, [ + ['aliases' => ['1', 'true', 'verified', 'yes'], 'sql' => 'users.email_verified_at is not null'], + ['aliases' => ['0', 'false', 'unverified', 'no'], 'sql' => 'users.email_verified_at is null'], + ]); + RepoQuery::addEnumFilter($where, $params, $loginStatus, [ + ['aliases' => ['never', 'none', 'no'], 'sql' => 'users.last_login_at is null'], + ['aliases' => ['ever', 'logged', 'yes'], 'sql' => 'users.last_login_at is not null'], + ]); + if (!empty($customFieldFilters['select']) && is_array($customFieldFilters['select'])) { + foreach ($customFieldFilters['select'] as $definitionId => $optionId) { + $definitionId = (int) $definitionId; + $optionId = (int) $optionId; + if ($definitionId <= 0 || $optionId <= 0) { + continue; + } + $where[] = 'exists (select 1 from user_custom_field_values ucfv ' . + 'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.option_id = ?)'; + $params[] = (string) $definitionId; + $params[] = (string) $optionId; + } + } + if (!empty($customFieldFilters['boolean']) && is_array($customFieldFilters['boolean'])) { + foreach ($customFieldFilters['boolean'] as $definitionId => $boolValue) { + $definitionId = (int) $definitionId; + $boolValue = (int) $boolValue; + if ($definitionId <= 0 || ($boolValue !== 0 && $boolValue !== 1)) { + continue; + } + $where[] = 'exists (select 1 from user_custom_field_values ucfv ' . + 'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.value_bool = ?)'; + $params[] = (string) $definitionId; + $params[] = (string) $boolValue; + } + } + if (!empty($customFieldFilters['multiselect']) && is_array($customFieldFilters['multiselect'])) { + foreach ($customFieldFilters['multiselect'] as $definitionId => $optionIds) { + $definitionId = (int) $definitionId; + $optionIds = RepoQuery::normalizeIdList($optionIds); + if ($definitionId <= 0 || !$optionIds) { + continue; + } + $where[] = 'exists (select 1 from user_custom_field_values ucfv ' . + 'join user_custom_field_value_options ucfvo on ucfvo.value_id = ucfv.id ' . + 'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfvo.option_id in (???))'; + $params[] = (string) $definitionId; + $params[] = array_map('strval', $optionIds); + } + } + if (!empty($customFieldFilters['date']) && is_array($customFieldFilters['date'])) { + foreach ($customFieldFilters['date'] as $definitionId => $bounds) { + $definitionId = (int) $definitionId; + $from = is_array($bounds) ? trim((string) ($bounds['from'] ?? '')) : ''; + $to = is_array($bounds) ? trim((string) ($bounds['to'] ?? '')) : ''; + if ($definitionId <= 0 || ($from === '' && $to === '')) { + continue; + } + if ($from !== '') { + $where[] = 'exists (select 1 from user_custom_field_values ucfv ' . + 'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.value_date >= ?)'; + $params[] = (string) $definitionId; + $params[] = $from; + } + if ($to !== '') { + $where[] = 'exists (select 1 from user_custom_field_values ucfv ' . + 'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.value_date <= ?)'; + $params[] = (string) $definitionId; + $params[] = $to; + } + } + } + if (!empty($options['tenantUserId'])) { + $tenantUserId = (int) $options['tenantUserId']; + if ($tenantUserId > 0) { + if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) { + $where[] = 'exists (select 1 from user_tenants ut join user_tenants ut2 on ut2.tenant_id = ut.tenant_id ' . + "join tenants t on t.id = ut.tenant_id and t.status = 'active' " . + 'where ut2.user_id = ? and ut.user_id = users.id)'; + $params[] = (string) $tenantUserId; + } else { + $where[] = '(not exists (select 1 from user_tenants ut where ut.user_id = users.id) ' . + 'or exists (select 1 from user_tenants ut join user_tenants ut2 on ut2.tenant_id = ut.tenant_id ' . + "join tenants t on t.id = ut.tenant_id and t.status = 'active' " . + 'where ut2.user_id = ? and ut.user_id = users.id))'; + $params[] = (string) $tenantUserId; + } + } + } + + $whereSql = $where ? (' where ' . implode(' and ', $where)) : ''; + return [$whereSql, $params]; + } + + private function buildListQuery(string $whereSql, string $order, string $dir): string + { + return 'select users.id, users.uuid, users.first_name, users.last_name, users.display_name, users.email, users.profile_description, users.job_title, users.phone, users.mobile, users.short_dial, users.address, users.postal_code, users.city, users.country, users.region, users.hire_date, users.theme, users.primary_tenant_id, users.created_by, users.modified_by, users.created, users.modified, users.last_login_at, users.last_login_provider, users.active, ' . + 'pt.description as primary_tenant_label ' . + "from users left join tenants pt on pt.id = users.primary_tenant_id and pt.status = 'active'" . + $whereSql . + sprintf(' order by `users`.`%s` %s limit ? offset ?', $order, $dir); + } + + private function extractIds(array $list): array + { + $ids = []; + foreach ($list as $user) { + if (isset($user['id'])) { + $ids[] = (int) $user['id']; + } + } + return $ids; + } + + private function collectLabels(array $rows, string $userKey, string $labelKey): array + { + $labelsByUser = []; + foreach ($rows as $row) { + $userId = 0; + $label = ''; + if (isset($row[$userKey]) && is_array($row[$userKey])) { + $userId = (int) ($row[$userKey]['user_id'] ?? 0); + } + if (isset($row[$labelKey]) && is_array($row[$labelKey])) { + $label = (string) ($row[$labelKey]['description'] ?? ''); + } + if ($userId === 0 || $label === '') { + foreach ($row as $value) { + if (!is_array($value)) { + continue; + } + if ($userId === 0 && isset($value['user_id'])) { + $userId = (int) $value['user_id']; + } + if ($label === '' && isset($value['description'])) { + $label = (string) $value['description']; + } + } + } + if ($userId === 0 && isset($row['user_id'])) { + $userId = (int) $row['user_id']; + } + if ($label === '' && isset($row['description'])) { + $label = (string) $row['description']; + } + if ($userId > 0 && $label !== '') { + $labelsByUser[$userId] ??= []; + $labelsByUser[$userId][] = $label; + } + } + return $labelsByUser; + } + + private function hydrateUserLabels(array $list, array $options): array + { + $ids = $this->extractIds($list); + if (!$ids) { + return $list; + } + + $scopeUserId = (int) ($options['tenantUserId'] ?? 0); + $scopeToActiveTenants = $scopeUserId > 0; + $tenantLabelJoin = $scopeToActiveTenants + ? "join tenants t on t.id = ut.tenant_id and t.status = 'active' " + : 'join tenants t on t.id = ut.tenant_id '; + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $tenantScopeSql = ''; + $tenantScopeParams = []; + if ($scopeToActiveTenants) { + $tenantScopeSql = ' and exists (select 1 from user_tenants uts ' . + "join tenants ts on ts.id = uts.tenant_id and ts.status = 'active' " . + 'where uts.user_id = ? and uts.tenant_id = ut.tenant_id)'; + $tenantScopeParams[] = (string) $scopeUserId; + } + + $labelSql = 'select ut.user_id as user_id, t.id as tenant_id, t.description as description from user_tenants ut ' . $tenantLabelJoin . + 'where ut.user_id in (' . $placeholders . ')' . $tenantScopeSql . ' order by t.description asc'; + $labelRows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge( + [$labelSql], + array_merge(array_map('strval', $ids), $tenantScopeParams) + )); + $roleRows = DB::select( + 'select ur.user_id as user_id, r.description as description from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 ' . + 'where ur.user_id in (' . $placeholders . ') order by r.description asc', + ...array_map('strval', $ids) + ); + + $departmentScopeSql = ''; + $departmentScopeParams = []; + if ($scopeToActiveTenants) { + $departmentScopeSql = ' and exists (select 1 from user_tenants uts ' . + "join tenants ts on ts.id = uts.tenant_id and ts.status = 'active' " . + 'where uts.user_id = ? and uts.tenant_id = d.tenant_id)'; + $departmentScopeParams[] = (string) $scopeUserId; + } + $departmentLabelSql = 'select ud.user_id as user_id, d.description as description from user_departments ud ' . + 'join departments d on d.id = ud.department_id and d.active = 1 ' . + 'where ud.user_id in (' . $placeholders . ')' . $departmentScopeSql . ' order by d.description asc'; + $departmentRows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge( + [$departmentLabelSql], + array_merge(array_map('strval', $ids), $departmentScopeParams) + )); + + $tenantMapByUser = []; + foreach ($labelRows as $row) { + $userId = 0; + $tenantId = 0; + $label = ''; + if (isset($row['ut']) && is_array($row['ut'])) { + $userId = (int) ($row['ut']['user_id'] ?? 0); + } + if (isset($row['t']) && is_array($row['t'])) { + $tenantId = (int) ($row['t']['tenant_id'] ?? 0); + $label = (string) ($row['t']['description'] ?? ''); + } + if ($userId === 0 || $tenantId === 0 || $label === '') { + foreach ($row as $value) { + if (!is_array($value)) { + continue; + } + if ($userId === 0 && isset($value['user_id'])) { + $userId = (int) $value['user_id']; + } + if ($tenantId === 0 && isset($value['tenant_id'])) { + $tenantId = (int) $value['tenant_id']; + } + if ($label === '' && isset($value['description'])) { + $label = (string) $value['description']; + } + } + } + if ($userId === 0 && isset($row['user_id'])) { + $userId = (int) $row['user_id']; + } + if ($tenantId === 0 && isset($row['tenant_id'])) { + $tenantId = (int) $row['tenant_id']; + } + if ($label === '' && isset($row['description'])) { + $label = (string) $row['description']; + } + if ($userId > 0 && $tenantId > 0 && $label !== '') { + $tenantMapByUser[$userId] ??= []; + $tenantMapByUser[$userId][$tenantId] = $label; + } + } + $tenantLabelsByUser = []; + foreach ($tenantMapByUser as $userId => $map) { + $tenantLabelsByUser[$userId] = array_values($map); + } + $roleLabelsByUser = $this->collectLabels($roleRows, 'ur', 'r'); + $departmentLabelsByUser = $this->collectLabels($departmentRows, 'ud', 'd'); + + foreach ($list as &$user) { + $userId = (int) ($user['id'] ?? 0); + $primaryId = (int) ($user['primary_tenant_id'] ?? 0); + $user['tenant_labels'] = $tenantLabelsByUser[$userId] ?? []; + if ($primaryId > 0 && isset($tenantMapByUser[$userId][$primaryId])) { + $user['primary_tenant_label'] = $tenantMapByUser[$userId][$primaryId]; + } + $user['role_labels'] = $roleLabelsByUser[$userId] ?? []; + $user['department_labels'] = $departmentLabelsByUser[$userId] ?? []; + } + unset($user); + + return $list; + } + + private function unwrapList($rows): array + { + if (!is_array($rows)) { + return []; + } + $list = []; + foreach ($rows as $row) { + $user = $row['users'] ?? null; + if (is_array($user)) { + foreach ($row as $key => $value) { + if ($key === 'users' || is_array($value)) { + continue; + } + $user[$key] = $value; + } + if (isset($row['pt']) && is_array($row['pt'])) { + $label = (string) ($row['pt']['description'] ?? ''); + if ($label !== '') { + $user['primary_tenant_label'] = $label; + } + } + $list[] = $user; + } + } + return $list; + } + + public function listPaged(array $options): array + { + $allowedOrder = ['id', 'uuid', 'first_name', 'last_name', 'display_name', 'email', 'created', 'modified', 'active', 'last_login_at']; + [$limit, $offset] = RepoQuery::sanitizeLimitOffset($options); + [$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder); + [$whereSql, $params] = $this->buildUserFilters($options); + + $count = DB::selectValue('select count(*) from users' . $whereSql, ...$params); + $total = $count ? (int) $count : 0; + + $query = $this->buildListQuery($whereSql, $order, $dir); + $queryParams = array_merge($params, [(string) $limit, (string) $offset]); + $rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams)); + + $list = $this->unwrapList($rows); + $list = $this->hydrateUserLabels($list, $options); + + return [ + 'total' => $total, + 'rows' => $list, + ]; + } +} diff --git a/lib/Repository/User/UserReadRepository.php b/lib/Repository/User/UserReadRepository.php new file mode 100644 index 0000000..819b86f --- /dev/null +++ b/lib/Repository/User/UserReadRepository.php @@ -0,0 +1,154 @@ +unwrap($row); + } + + public function find(int $id): ?array + { + $row = DB::selectOne( + 'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, last_login_at, last_login_provider, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, modified_by, created, modified, active, authz_version from users where id = ? limit 1', + (string) $id + ); + return $this->unwrap($row); + } + + public function findByUuid(string $uuid): ?array + { + $row = DB::selectOne( + 'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, last_login_at, last_login_provider, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, modified_by, created, modified, active, authz_version, active_changed_at, active_changed_by from users where uuid = ? limit 1', + $uuid + ); + return $this->unwrap($row); + } + + public function findByEmail(string $email): ?array + { + $row = DB::selectOne( + 'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, last_login_at, last_login_provider, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, modified_by, created, modified, active, authz_version, active_changed_at, active_changed_by from users where email = ? limit 1', + $email + ); + return $this->unwrap($row); + } + + public function listPrivilegedUserIdsByPermissionKeys(array $permissionKeys): array + { + $keys = array_values(array_unique(array_filter(array_map( + static fn ($key) => trim((string) $key), + $permissionKeys + )))); + if (!$keys) { + return []; + } + + $placeholders = implode(',', array_fill(0, count($keys), '?')); + $rows = DB::select( + 'select distinct ur.user_id from user_roles ur ' . + 'join roles r on r.id = ur.role_id and r.active = 1 ' . + 'join role_permissions rp on rp.role_id = ur.role_id ' . + 'join permissions p on p.id = rp.permission_id and p.active = 1 ' . + "where p.`key` in ($placeholders)", + ...$keys + ); + if (!is_array($rows)) { + return []; + } + + $ids = []; + foreach ($rows as $row) { + $data = $row['ur'] ?? $row['user_roles'] ?? $row; + $userId = (int) ($data['user_id'] ?? $row['user_id'] ?? 0); + if ($userId > 0) { + $ids[] = $userId; + } + } + return array_values(array_unique($ids)); + } + + public function listIdsForAutoDeactivate(int $days, array $excludedUserIds, int $limit = 500): array + { + if ($days <= 0 || $limit <= 0) { + return []; + } + + $excluded = array_values(array_unique(array_filter(array_map('intval', $excludedUserIds), static fn ($id) => $id > 0))); + $query = 'select id from users where active = 1 and coalesce(last_login_at, created) <= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ' . + (int) $days . + ' DAY)'; + $params = []; + if ($excluded) { + $placeholders = implode(',', array_fill(0, count($excluded), '?')); + $query .= " and id not in ($placeholders)"; + $params = array_map('strval', $excluded); + } + $query .= ' order by coalesce(last_login_at, created) asc limit ?'; + $params[] = (string) $limit; + $rows = DB::select($query, ...$params); + if (!is_array($rows)) { + return []; + } + return $this->extractIdList($rows); + } + + public function listIdsForAutoDelete(int $days, array $excludedUserIds, int $limit = 500): array + { + if ($days <= 0 || $limit <= 0) { + return []; + } + + $excluded = array_values(array_unique(array_filter(array_map('intval', $excludedUserIds), static fn ($id) => $id > 0))); + $query = 'select id from users where active = 0 and active_changed_at is not null and active_changed_at <= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ' . + (int) $days . + ' DAY)'; + $params = []; + if ($excluded) { + $placeholders = implode(',', array_fill(0, count($excluded), '?')); + $query .= " and id not in ($placeholders)"; + $params = array_map('strval', $excluded); + } + $query .= ' order by active_changed_at asc limit ?'; + $params[] = (string) $limit; + $rows = DB::select($query, ...$params); + if (!is_array($rows)) { + return []; + } + return $this->extractIdList($rows); + } + + private function unwrap(?array $row): ?array + { + if (!$row) { + return null; + } + return $row['users'] ?? null; + } + + private function extractIdList(array $rows): array + { + $ids = []; + foreach ($rows as $row) { + $data = $row['users'] ?? $row; + $id = (int) ($data['id'] ?? $row['id'] ?? 0); + if ($id > 0) { + $ids[] = $id; + } + } + return array_values(array_unique($ids)); + } +} diff --git a/lib/Repository/User/UserRepository.php b/lib/Repository/User/UserRepository.php deleted file mode 100644 index 86f49e6..0000000 --- a/lib/Repository/User/UserRepository.php +++ /dev/null @@ -1,841 +0,0 @@ - ['1', 'true', 'active'], 'sql' => 'users.active = ?', 'params' => ['1']], - ['aliases' => ['0', 'false', 'inactive'], 'sql' => 'users.active = ?', 'params' => ['0']], - ]); - - if ($createdFrom !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) { - $where[] = 'users.created >= ?'; - $params[] = $createdFrom . ' 00:00:00'; - } - if ($createdTo !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) { - $where[] = 'users.created <= ?'; - $params[] = $createdTo . ' 23:59:59'; - } - if ($tenantUuids) { - $where[] = 'exists (select 1 from user_tenants ut2 join tenants t2 on t2.id = ut2.tenant_id where ut2.user_id = users.id and t2.uuid in (???))'; - $params[] = array_values($tenantUuids); - } else { - RepoQuery::addEqualsFilter( - $where, - $params, - $tenant, - 'exists (select 1 from user_tenants ut2 join tenants t2 on t2.id = ut2.tenant_id where ut2.user_id = users.id and t2.uuid = ?)' - ); - } - if ($roleIds) { - $where[] = 'exists (select 1 from user_roles ur2 where ur2.user_id = users.id and ur2.role_id in (???))'; - $params[] = array_map('strval', $roleIds); - } - if ($departmentIds) { - $where[] = 'exists (select 1 from user_departments ud2 where ud2.user_id = users.id and ud2.department_id in (???))'; - $params[] = array_map('strval', $departmentIds); - } - RepoQuery::addEnumFilter($where, $params, $emailVerified, [ - ['aliases' => ['1', 'true', 'verified', 'yes'], 'sql' => 'users.email_verified_at is not null'], - ['aliases' => ['0', 'false', 'unverified', 'no'], 'sql' => 'users.email_verified_at is null'], - ]); - RepoQuery::addEnumFilter($where, $params, $loginStatus, [ - ['aliases' => ['never', 'none', 'no'], 'sql' => 'users.last_login_at is null'], - ['aliases' => ['ever', 'logged', 'yes'], 'sql' => 'users.last_login_at is not null'], - ]); - if (!empty($customFieldFilters['select']) && is_array($customFieldFilters['select'])) { - foreach ($customFieldFilters['select'] as $definitionId => $optionId) { - $definitionId = (int) $definitionId; - $optionId = (int) $optionId; - if ($definitionId <= 0 || $optionId <= 0) { - continue; - } - $where[] = 'exists (select 1 from user_custom_field_values ucfv ' . - 'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.option_id = ?)'; - $params[] = (string) $definitionId; - $params[] = (string) $optionId; - } - } - if (!empty($customFieldFilters['boolean']) && is_array($customFieldFilters['boolean'])) { - foreach ($customFieldFilters['boolean'] as $definitionId => $boolValue) { - $definitionId = (int) $definitionId; - $boolValue = (int) $boolValue; - if ($definitionId <= 0 || ($boolValue !== 0 && $boolValue !== 1)) { - continue; - } - $where[] = 'exists (select 1 from user_custom_field_values ucfv ' . - 'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.value_bool = ?)'; - $params[] = (string) $definitionId; - $params[] = (string) $boolValue; - } - } - if (!empty($customFieldFilters['multiselect']) && is_array($customFieldFilters['multiselect'])) { - foreach ($customFieldFilters['multiselect'] as $definitionId => $optionIds) { - $definitionId = (int) $definitionId; - $optionIds = RepoQuery::normalizeIdList($optionIds); - if ($definitionId <= 0 || !$optionIds) { - continue; - } - $where[] = 'exists (select 1 from user_custom_field_values ucfv ' . - 'join user_custom_field_value_options ucfvo on ucfvo.value_id = ucfv.id ' . - 'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfvo.option_id in (???))'; - $params[] = (string) $definitionId; - $params[] = array_map('strval', $optionIds); - } - } - if (!empty($customFieldFilters['date']) && is_array($customFieldFilters['date'])) { - foreach ($customFieldFilters['date'] as $definitionId => $bounds) { - $definitionId = (int) $definitionId; - $from = is_array($bounds) ? trim((string) ($bounds['from'] ?? '')) : ''; - $to = is_array($bounds) ? trim((string) ($bounds['to'] ?? '')) : ''; - if ($definitionId <= 0 || ($from === '' && $to === '')) { - continue; - } - if ($from !== '') { - $where[] = 'exists (select 1 from user_custom_field_values ucfv ' . - 'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.value_date >= ?)'; - $params[] = (string) $definitionId; - $params[] = $from; - } - if ($to !== '') { - $where[] = 'exists (select 1 from user_custom_field_values ucfv ' . - 'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.value_date <= ?)'; - $params[] = (string) $definitionId; - $params[] = $to; - } - } - } - if (!empty($options['tenantUserId'])) { - $tenantUserId = (int) $options['tenantUserId']; - if ($tenantUserId > 0) { - if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) { - $where[] = 'exists (select 1 from user_tenants ut join user_tenants ut2 on ut2.tenant_id = ut.tenant_id ' . - "join tenants t on t.id = ut.tenant_id and t.status = 'active' " . - 'where ut2.user_id = ? and ut.user_id = users.id)'; - $params[] = (string) $tenantUserId; - } else { - $where[] = '(not exists (select 1 from user_tenants ut where ut.user_id = users.id) ' . - 'or exists (select 1 from user_tenants ut join user_tenants ut2 on ut2.tenant_id = ut.tenant_id ' . - "join tenants t on t.id = ut.tenant_id and t.status = 'active' " . - 'where ut2.user_id = ? and ut.user_id = users.id))'; - $params[] = (string) $tenantUserId; - } - } - } - - $whereSql = $where ? (' where ' . implode(' and ', $where)) : ''; - return [$whereSql, $params]; - } - - private static function buildListQuery(string $whereSql, string $order, string $dir): string - { - return 'select users.id, users.uuid, users.first_name, users.last_name, users.display_name, users.email, users.profile_description, users.job_title, users.phone, users.mobile, users.short_dial, users.address, users.postal_code, users.city, users.country, users.region, users.hire_date, users.theme, users.primary_tenant_id, users.created_by, users.modified_by, users.created, users.modified, users.last_login_at, users.last_login_provider, users.active, ' . - 'pt.description as primary_tenant_label ' . - "from users left join tenants pt on pt.id = users.primary_tenant_id and pt.status = 'active'" . - $whereSql . - sprintf(' order by `users`.`%s` %s limit ? offset ?', $order, $dir); - } - - private static function extractIds(array $list): array - { - $ids = []; - foreach ($list as $user) { - if (isset($user['id'])) { - $ids[] = (int) $user['id']; - } - } - return $ids; - } - - private static function collectLabels(array $rows, string $userKey, string $labelKey): array - { - $labelsByUser = []; - foreach ($rows as $row) { - $userId = 0; - $label = ''; - if (isset($row[$userKey]) && is_array($row[$userKey])) { - $userId = (int) ($row[$userKey]['user_id'] ?? 0); - } - if (isset($row[$labelKey]) && is_array($row[$labelKey])) { - $label = (string) ($row[$labelKey]['description'] ?? ''); - } - if ($userId === 0 || $label === '') { - foreach ($row as $value) { - if (!is_array($value)) { - continue; - } - if ($userId === 0 && isset($value['user_id'])) { - $userId = (int) $value['user_id']; - } - if ($label === '' && isset($value['description'])) { - $label = (string) $value['description']; - } - } - } - if ($userId === 0 && isset($row['user_id'])) { - $userId = (int) $row['user_id']; - } - if ($label === '' && isset($row['description'])) { - $label = (string) $row['description']; - } - if ($userId > 0 && $label !== '') { - $labelsByUser[$userId] ??= []; - $labelsByUser[$userId][] = $label; - } - } - return $labelsByUser; - } - - private static function hydrateUserLabels(array $list, array $options): array - { - $ids = self::extractIds($list); - if (!$ids) { - return $list; - } - - $scopeUserId = (int) ($options['tenantUserId'] ?? 0); - $scopeToActiveTenants = $scopeUserId > 0; - $tenantLabelJoin = $scopeToActiveTenants - ? "join tenants t on t.id = ut.tenant_id and t.status = 'active' " - : 'join tenants t on t.id = ut.tenant_id '; - $placeholders = implode(',', array_fill(0, count($ids), '?')); - $tenantScopeSql = ''; - $tenantScopeParams = []; - if ($scopeToActiveTenants) { - $tenantScopeSql = ' and exists (select 1 from user_tenants uts ' . - "join tenants ts on ts.id = uts.tenant_id and ts.status = 'active' " . - 'where uts.user_id = ? and uts.tenant_id = ut.tenant_id)'; - $tenantScopeParams[] = (string) $scopeUserId; - } - - $labelSql = 'select ut.user_id as user_id, t.id as tenant_id, t.description as description from user_tenants ut ' . $tenantLabelJoin . - 'where ut.user_id in (' . $placeholders . ')' . $tenantScopeSql . ' order by t.description asc'; - $labelRows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge( - [$labelSql], - array_merge(array_map('strval', $ids), $tenantScopeParams) - )); - $roleRows = DB::select( - 'select ur.user_id as user_id, r.description as description from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 ' . - 'where ur.user_id in (' . $placeholders . ') order by r.description asc', - ...array_map('strval', $ids) - ); - - $departmentScopeSql = ''; - $departmentScopeParams = []; - if ($scopeToActiveTenants) { - $departmentScopeSql = ' and exists (select 1 from user_tenants uts ' . - "join tenants ts on ts.id = uts.tenant_id and ts.status = 'active' " . - 'where uts.user_id = ? and uts.tenant_id = d.tenant_id)'; - $departmentScopeParams[] = (string) $scopeUserId; - } - $departmentLabelSql = 'select ud.user_id as user_id, d.description as description from user_departments ud ' . - 'join departments d on d.id = ud.department_id and d.active = 1 ' . - 'where ud.user_id in (' . $placeholders . ')' . $departmentScopeSql . ' order by d.description asc'; - $departmentRows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge( - [$departmentLabelSql], - array_merge(array_map('strval', $ids), $departmentScopeParams) - )); - - $tenantMapByUser = []; - foreach ($labelRows as $row) { - $userId = 0; - $tenantId = 0; - $label = ''; - if (isset($row['ut']) && is_array($row['ut'])) { - $userId = (int) ($row['ut']['user_id'] ?? 0); - } - if (isset($row['t']) && is_array($row['t'])) { - $tenantId = (int) ($row['t']['tenant_id'] ?? 0); - $label = (string) ($row['t']['description'] ?? ''); - } - if ($userId === 0 || $tenantId === 0 || $label === '') { - foreach ($row as $value) { - if (!is_array($value)) { - continue; - } - if ($userId === 0 && isset($value['user_id'])) { - $userId = (int) $value['user_id']; - } - if ($tenantId === 0 && isset($value['tenant_id'])) { - $tenantId = (int) $value['tenant_id']; - } - if ($label === '' && isset($value['description'])) { - $label = (string) $value['description']; - } - } - } - if ($userId === 0 && isset($row['user_id'])) { - $userId = (int) $row['user_id']; - } - if ($tenantId === 0 && isset($row['tenant_id'])) { - $tenantId = (int) $row['tenant_id']; - } - if ($label === '' && isset($row['description'])) { - $label = (string) $row['description']; - } - if ($userId > 0 && $tenantId > 0 && $label !== '') { - $tenantMapByUser[$userId] ??= []; - $tenantMapByUser[$userId][$tenantId] = $label; - } - } - $tenantLabelsByUser = []; - foreach ($tenantMapByUser as $userId => $map) { - $tenantLabelsByUser[$userId] = array_values($map); - } - $roleLabelsByUser = self::collectLabels($roleRows, 'ur', 'r'); - $departmentLabelsByUser = self::collectLabels($departmentRows, 'ud', 'd'); - - foreach ($list as &$user) { - $userId = (int) ($user['id'] ?? 0); - $primaryId = (int) ($user['primary_tenant_id'] ?? 0); - $user['tenant_labels'] = $tenantLabelsByUser[$userId] ?? []; - if ($primaryId > 0 && isset($tenantMapByUser[$userId][$primaryId])) { - $user['primary_tenant_label'] = $tenantMapByUser[$userId][$primaryId]; - } - $user['role_labels'] = $roleLabelsByUser[$userId] ?? []; - $user['department_labels'] = $departmentLabelsByUser[$userId] ?? []; - } - unset($user); - - return $list; - } - - private static function unwrap(?array $row): ?array - { - if (!$row) { - return null; - } - return $row['users'] ?? null; - } - - private static function unwrapList($rows): array - { - if (!is_array($rows)) { - return []; - } - $list = []; - foreach ($rows as $row) { - $user = $row['users'] ?? null; - if (is_array($user)) { - foreach ($row as $key => $value) { - if ($key === 'users' || is_array($value)) { - continue; - } - $user[$key] = $value; - } - if (isset($row['pt']) && is_array($row['pt'])) { - $label = (string) ($row['pt']['description'] ?? ''); - if ($label !== '') { - $user['primary_tenant_label'] = $label; - } - } - $list[] = $user; - } - } - return $list; - } - - public static function updateLastLogin(int $userId, string $provider = 'local'): void - { - if ($userId <= 0) { - return; - } - $provider = trim(strtolower($provider)); - if (!in_array($provider, ['local', 'microsoft'], true)) { - $provider = 'local'; - } - DB::query('update users set last_login_at = UTC_TIMESTAMP(), last_login_provider = ? where id = ?', $provider, (string) $userId); - } - - public static function findAuthzSnapshot(int $userId): ?array - { - if ($userId <= 0) { - return null; - } - - $row = DB::selectOne( - 'select id, active, authz_version, locale, theme, current_tenant_id from users where id = ? limit 1', - (string) $userId - ); - - return self::unwrap($row); - } - - public static function bumpAuthzVersion(int $userId): bool - { - if ($userId <= 0) { - return false; - } - - $result = DB::update( - 'update users set authz_version = authz_version + 1 where id = ?', - (string) $userId - ); - - return $result !== false; - } - - public static function bumpAuthzVersionByUserIds(array $userIds): int - { - $userIds = array_values(array_unique(array_map('intval', $userIds))); - $userIds = array_values(array_filter($userIds, static fn ($id) => $id > 0)); - if (!$userIds) { - return 0; - } - - $placeholders = implode(',', array_fill(0, count($userIds), '?')); - $result = DB::update( - "update users set authz_version = authz_version + 1 where id in ($placeholders)", - ...array_map('strval', $userIds) - ); - - return $result !== false ? (int) $result : 0; - } - - public static function listPaged(array $options): array - { - $allowedOrder = ['id', 'uuid', 'first_name', 'last_name', 'display_name', 'email', 'created', 'modified', 'active', 'last_login_at']; - [$limit, $offset] = RepoQuery::sanitizeLimitOffset($options); - [$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder); - [$whereSql, $params] = self::buildUserFilters($options); - - $count = DB::selectValue('select count(*) from users' . $whereSql, ...$params); - $total = $count ? (int) $count : 0; - - $query = self::buildListQuery($whereSql, $order, $dir); - $queryParams = array_merge($params, [(string) $limit, (string) $offset]); - $rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams)); - - $list = self::unwrapList($rows); - $list = self::hydrateUserLabels($list, $options); - - $result = [ - 'total' => $total, - 'rows' => $list, - ]; - return $result; - } - - public static function find(int $id): ?array - { - $row = DB::selectOne( - 'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, last_login_at, last_login_provider, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, modified_by, created, modified, active, authz_version from users where id = ? limit 1', - (string) $id - ); - return self::unwrap($row); - } - - public static function findByUuid(string $uuid): ?array - { - $row = DB::selectOne( - 'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, last_login_at, last_login_provider, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, modified_by, created, modified, active, authz_version, active_changed_at, active_changed_by from users where uuid = ? limit 1', - $uuid - ); - return self::unwrap($row); - } - - public static function findByEmail(string $email): ?array - { - $row = DB::selectOne( - 'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, last_login_at, last_login_provider, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, modified_by, created, modified, active, authz_version, active_changed_at, active_changed_by from users where email = ? limit 1', - $email - ); - return self::unwrap($row); - } - - private static function buildDisplayName(array $data): string - { - $first = trim((string) ($data['first_name'] ?? '')); - $last = trim((string) ($data['last_name'] ?? '')); - $name = trim($first . ' ' . $last); - if ($name === '') { - $name = trim((string) ($data['email'] ?? '')); - } - return $name; - } - - public static function create(array $data): int|false - { - $hash = password_hash($data['password'], PASSWORD_DEFAULT); - return DB::insert( - 'insert into users (uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, created, active, active_changed_at, active_changed_by) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),?,?,?)', - $data['uuid'] ?? RepoQuery::uuidV4(), - $data['first_name'], - $data['last_name'], - self::buildDisplayName($data), - $data['email'], - $data['profile_description'] ?? null, - $data['job_title'] ?? null, - $data['phone'] ?? null, - $data['mobile'] ?? null, - $data['short_dial'] ?? null, - $data['address'] ?? null, - $data['postal_code'] ?? null, - $data['city'] ?? null, - $data['country'] ?? null, - $data['region'] ?? null, - $data['hire_date'] ?? null, - $hash, - $data['locale'] ?? null, - $data['totp_secret'], - $data['theme'] ?? 'light', - $data['primary_tenant_id'] ?? null, - $data['current_tenant_id'] ?? $data['primary_tenant_id'] ?? null, - $data['created_by'] ?? null, - $data['active'], - $data['active_changed_at'] ?? null, - $data['active_changed_by'] ?? null - ); - } - - public static function update(int $id, array $data): bool - { - $fields = [ - 'first_name' => $data['first_name'], - 'last_name' => $data['last_name'], - 'display_name' => self::buildDisplayName($data), - 'email' => $data['email'], - 'profile_description' => $data['profile_description'] ?? null, - 'job_title' => $data['job_title'] ?? null, - 'phone' => $data['phone'] ?? null, - 'mobile' => $data['mobile'] ?? null, - 'short_dial' => $data['short_dial'] ?? null, - 'address' => $data['address'] ?? null, - 'postal_code' => $data['postal_code'] ?? null, - 'city' => $data['city'] ?? null, - 'country' => $data['country'] ?? null, - 'region' => $data['region'] ?? null, - 'hire_date' => $data['hire_date'] ?? null, - 'totp_secret' => $data['totp_secret'], - 'active' => $data['active'], - ]; - if (array_key_exists('locale', $data)) { - $fields['locale'] = $data['locale']; - } - if (array_key_exists('theme', $data)) { - $fields['theme'] = $data['theme']; - } - if (array_key_exists('primary_tenant_id', $data)) { - $fields['primary_tenant_id'] = $data['primary_tenant_id']; - } - if (array_key_exists('current_tenant_id', $data)) { - $fields['current_tenant_id'] = $data['current_tenant_id']; - } - if (array_key_exists('modified_by', $data)) { - $fields['modified_by'] = $data['modified_by']; - } - if (array_key_exists('active_changed_at', $data)) { - $fields['active_changed_at'] = $data['active_changed_at']; - } - if (array_key_exists('active_changed_by', $data)) { - $fields['active_changed_by'] = $data['active_changed_by']; - } - - if (!empty($data['password'])) { - $fields['password'] = password_hash($data['password'], PASSWORD_DEFAULT); - } - - $setParts = []; - $params = []; - foreach ($fields as $field => $value) { - $setParts[] = sprintf('`%s` = ?', $field); - $params[] = $value; - } - $params[] = (string) $id; - - $query = 'update users set ' . implode(', ', $setParts) . ' where id = ?'; - $result = DB::update($query, ...$params); - return $result !== false; - } - - public static function setActive(int $id, bool $active, ?int $changedBy = null): bool - { - $result = DB::update( - 'update users set active = ?, active_changed_at = NOW(), active_changed_by = ? where id = ?', - $active ? 1 : 0, - $changedBy, - (string) $id - ); - return $result !== false; - } - - public static function setCurrentTenant(int $id, int $tenantId): bool - { - $result = DB::update( - 'update users set current_tenant_id = ? where id = ?', - (string) $tenantId, - (string) $id - ); - return $result !== false; - } - - public static function setActiveByUuids(array $uuids, bool $active, ?int $modifiedBy = null): bool - { - $uuids = array_values(array_filter(array_map('trim', $uuids))); - if (!$uuids) { - return false; - } - $placeholders = implode(',', array_fill(0, count($uuids), '?')); - $query = "update users set active = ?, modified_by = ?, active_changed_at = NOW(), active_changed_by = ? where uuid in ($placeholders)"; - $params = array_merge([$active ? 1 : 0, $modifiedBy, $modifiedBy], $uuids); - $result = DB::update($query, ...$params); - return $result !== false; - } - - public static function listPrivilegedUserIdsByPermissionKeys(array $permissionKeys): array - { - $keys = array_values(array_unique(array_filter(array_map( - static fn ($key) => trim((string) $key), - $permissionKeys - )))); - if (!$keys) { - return []; - } - - $placeholders = implode(',', array_fill(0, count($keys), '?')); - $rows = DB::select( - 'select distinct ur.user_id from user_roles ur ' . - 'join roles r on r.id = ur.role_id and r.active = 1 ' . - 'join role_permissions rp on rp.role_id = ur.role_id ' . - 'join permissions p on p.id = rp.permission_id and p.active = 1 ' . - "where p.`key` in ($placeholders)", - ...$keys - ); - if (!is_array($rows)) { - return []; - } - - $ids = []; - foreach ($rows as $row) { - $data = $row['ur'] ?? $row['user_roles'] ?? $row; - $userId = (int) ($data['user_id'] ?? $row['user_id'] ?? 0); - if ($userId > 0) { - $ids[] = $userId; - } - } - return array_values(array_unique($ids)); - } - - public static function listIdsForAutoDeactivate(int $days, array $excludedUserIds, int $limit = 500): array - { - if ($days <= 0 || $limit <= 0) { - return []; - } - - $excluded = array_values(array_unique(array_filter(array_map('intval', $excludedUserIds), static fn ($id) => $id > 0))); - $query = 'select id from users where active = 1 and coalesce(last_login_at, created) <= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ' . - (int) $days . - ' DAY)'; - $params = []; - if ($excluded) { - $placeholders = implode(',', array_fill(0, count($excluded), '?')); - $query .= " and id not in ($placeholders)"; - $params = array_map('strval', $excluded); - } - $query .= ' order by coalesce(last_login_at, created) asc limit ?'; - $params[] = (string) $limit; - $rows = DB::select($query, ...$params); - if (!is_array($rows)) { - return []; - } - return self::extractIdList($rows); - } - - public static function listIdsForAutoDelete(int $days, array $excludedUserIds, int $limit = 500): array - { - if ($days <= 0 || $limit <= 0) { - return []; - } - - $excluded = array_values(array_unique(array_filter(array_map('intval', $excludedUserIds), static fn ($id) => $id > 0))); - $query = 'select id from users where active = 0 and active_changed_at is not null and active_changed_at <= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ' . - (int) $days . - ' DAY)'; - $params = []; - if ($excluded) { - $placeholders = implode(',', array_fill(0, count($excluded), '?')); - $query .= " and id not in ($placeholders)"; - $params = array_map('strval', $excluded); - } - $query .= ' order by active_changed_at asc limit ?'; - $params[] = (string) $limit; - $rows = DB::select($query, ...$params); - if (!is_array($rows)) { - return []; - } - return self::extractIdList($rows); - } - - public static function setInactiveByIds(array $userIds, ?int $changedBy = null): int - { - $ids = array_values(array_unique(array_filter(array_map('intval', $userIds), static fn ($id) => $id > 0))); - if (!$ids) { - return 0; - } - - $placeholders = implode(',', array_fill(0, count($ids), '?')); - $query = "update users set active = 0, modified_by = ?, active_changed_at = UTC_TIMESTAMP(), active_changed_by = ? where active = 1 and id in ($placeholders)"; - $params = array_merge([$changedBy, $changedBy], array_map('strval', $ids)); - $result = DB::update($query, ...$params); - return $result !== false ? (int) $result : 0; - } - - public static function deleteByIds(array $userIds): int - { - $ids = array_values(array_unique(array_filter(array_map('intval', $userIds), static fn ($id) => $id > 0))); - if (!$ids) { - return 0; - } - $placeholders = implode(',', array_fill(0, count($ids), '?')); - $result = DB::delete( - "delete from users where id in ($placeholders)", - ...array_map('strval', $ids) - ); - return $result !== false ? (int) $result : 0; - } - - public static function setLocale(int $id, string $locale): bool - { - $result = DB::update('update users set locale = ? where id = ?', $locale, (string) $id); - return $result !== false; - } - - public static function setTheme(int $id, string $theme): bool - { - $result = DB::update('update users set theme = ? where id = ?', $theme, (string) $id); - return $result !== false; - } - - public static function updateProfileFieldsFromSso(int $id, array $fields): bool - { - if ($id <= 0) { - return false; - } - - $existing = self::find($id); - if (!$existing) { - return false; - } - - $allowed = ['first_name', 'last_name', 'phone', 'mobile']; - $updates = []; - foreach ($allowed as $field) { - if (!array_key_exists($field, $fields)) { - continue; - } - $value = trim((string) $fields[$field]); - if ($value === '') { - continue; - } - if ((string) ($existing[$field] ?? '') === $value) { - continue; - } - $updates[$field] = $value; - } - - if (!$updates) { - return true; - } - - if (isset($updates['first_name']) || isset($updates['last_name'])) { - $displayData = [ - 'first_name' => $updates['first_name'] ?? (string) ($existing['first_name'] ?? ''), - 'last_name' => $updates['last_name'] ?? (string) ($existing['last_name'] ?? ''), - 'email' => (string) ($existing['email'] ?? ''), - ]; - $updates['display_name'] = self::buildDisplayName($displayData); - } - - $setParts = []; - $params = []; - foreach ($updates as $field => $value) { - $setParts[] = sprintf('`%s` = ?', $field); - $params[] = $value; - } - $params[] = (string) $id; - - $result = DB::update( - 'update users set ' . implode(', ', $setParts) . ' where id = ?', - ...$params - ); - - return $result !== false; - } - - public static function setPassword(int $id, string $password): bool - { - $hash = password_hash($password, PASSWORD_DEFAULT); - $result = DB::update('update users set password = ?, modified_by = NULL where id = ?', $hash, (string) $id); - return $result !== false; - } - - public static function setEmailVerified(int $id): bool - { - $result = DB::update('update users set email_verified_at = NOW() where id = ?', (string) $id); - return $result !== false; - } - - public static function delete(int $id): bool - { - $result = DB::delete('delete from users where id = ?', (string) $id); - return $result !== false; - } - - public static function deleteByUuids(array $uuids): bool - { - $uuids = array_values(array_filter(array_map('trim', $uuids))); - if (!$uuids) { - return false; - } - $placeholders = implode(',', array_fill(0, count($uuids), '?')); - $query = "delete from users where uuid in ($placeholders)"; - $result = DB::delete($query, ...$uuids); - return $result !== false; - } - - private static function extractIdList(array $rows): array - { - $ids = []; - foreach ($rows as $row) { - $data = $row['users'] ?? $row; - $id = (int) ($data['id'] ?? $row['id'] ?? 0); - if ($id > 0) { - $ids[] = $id; - } - } - return array_values(array_unique($ids)); - } - -} diff --git a/lib/Repository/User/UserSavedFilterRepository.php b/lib/Repository/User/UserSavedFilterRepository.php index 977c7db..0598884 100644 --- a/lib/Repository/User/UserSavedFilterRepository.php +++ b/lib/Repository/User/UserSavedFilterRepository.php @@ -7,7 +7,7 @@ use MintyPHP\Repository\Support\RepoQuery; class UserSavedFilterRepository { - private static function unwrapList($rows): array + private function unwrapList($rows): array { if (!is_array($rows)) { return []; @@ -22,7 +22,7 @@ class UserSavedFilterRepository return $list; } - public static function listByUserAndContext(int $userId, string $context): array + public function listByUserAndContext(int $userId, string $context): array { if ($userId <= 0 || $context === '') { return []; @@ -32,10 +32,10 @@ class UserSavedFilterRepository (string) $userId, $context ); - return self::unwrapList($rows); + return $this->unwrapList($rows); } - public static function countByUserAndContext(int $userId, string $context): int + public function countByUserAndContext(int $userId, string $context): int { if ($userId <= 0 || $context === '') { return 0; @@ -48,7 +48,7 @@ class UserSavedFilterRepository return $count ? (int) $count : 0; } - public static function create(array $data): int|false + public function create(array $data): int|false { $userId = (int) ($data['user_id'] ?? 0); $context = trim((string) ($data['context'] ?? '')); @@ -67,7 +67,7 @@ class UserSavedFilterRepository ); } - public static function deleteByUuidForUserAndContext(string $uuid, int $userId, string $context): bool + public function deleteByUuidForUserAndContext(string $uuid, int $userId, string $context): bool { $uuid = trim($uuid); $context = trim($context); diff --git a/lib/Repository/User/UserWriteRepository.php b/lib/Repository/User/UserWriteRepository.php new file mode 100644 index 0000000..d7a37be --- /dev/null +++ b/lib/Repository/User/UserWriteRepository.php @@ -0,0 +1,319 @@ + $id > 0)); + if (!$userIds) { + return 0; + } + + $placeholders = implode(',', array_fill(0, count($userIds), '?')); + $result = DB::update( + "update users set authz_version = authz_version + 1 where id in ($placeholders)", + ...array_map('strval', $userIds) + ); + + return $result !== false ? (int) $result : 0; + } + + public function create(array $data): int|false + { + $hash = password_hash($data['password'], PASSWORD_DEFAULT); + return DB::insert( + 'insert into users (uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, created, active, active_changed_at, active_changed_by) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),?,?,?)', + $data['uuid'] ?? RepoQuery::uuidV4(), + $data['first_name'], + $data['last_name'], + $this->buildDisplayName($data), + $data['email'], + $data['profile_description'] ?? null, + $data['job_title'] ?? null, + $data['phone'] ?? null, + $data['mobile'] ?? null, + $data['short_dial'] ?? null, + $data['address'] ?? null, + $data['postal_code'] ?? null, + $data['city'] ?? null, + $data['country'] ?? null, + $data['region'] ?? null, + $data['hire_date'] ?? null, + $hash, + $data['locale'] ?? null, + $data['totp_secret'], + $data['theme'] ?? 'light', + $data['primary_tenant_id'] ?? null, + $data['current_tenant_id'] ?? $data['primary_tenant_id'] ?? null, + $data['created_by'] ?? null, + $data['active'], + $data['active_changed_at'] ?? null, + $data['active_changed_by'] ?? null + ); + } + + public function update(int $id, array $data): bool + { + $fields = [ + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'display_name' => $this->buildDisplayName($data), + 'email' => $data['email'], + 'profile_description' => $data['profile_description'] ?? null, + 'job_title' => $data['job_title'] ?? null, + 'phone' => $data['phone'] ?? null, + 'mobile' => $data['mobile'] ?? null, + 'short_dial' => $data['short_dial'] ?? null, + 'address' => $data['address'] ?? null, + 'postal_code' => $data['postal_code'] ?? null, + 'city' => $data['city'] ?? null, + 'country' => $data['country'] ?? null, + 'region' => $data['region'] ?? null, + 'hire_date' => $data['hire_date'] ?? null, + 'totp_secret' => $data['totp_secret'], + 'active' => $data['active'], + ]; + if (array_key_exists('locale', $data)) { + $fields['locale'] = $data['locale']; + } + if (array_key_exists('theme', $data)) { + $fields['theme'] = $data['theme']; + } + if (array_key_exists('primary_tenant_id', $data)) { + $fields['primary_tenant_id'] = $data['primary_tenant_id']; + } + if (array_key_exists('current_tenant_id', $data)) { + $fields['current_tenant_id'] = $data['current_tenant_id']; + } + if (array_key_exists('modified_by', $data)) { + $fields['modified_by'] = $data['modified_by']; + } + if (array_key_exists('active_changed_at', $data)) { + $fields['active_changed_at'] = $data['active_changed_at']; + } + if (array_key_exists('active_changed_by', $data)) { + $fields['active_changed_by'] = $data['active_changed_by']; + } + + if (!empty($data['password'])) { + $fields['password'] = password_hash($data['password'], PASSWORD_DEFAULT); + } + + $setParts = []; + $params = []; + foreach ($fields as $field => $value) { + $setParts[] = sprintf('`%s` = ?', $field); + $params[] = $value; + } + $params[] = (string) $id; + + $query = 'update users set ' . implode(', ', $setParts) . ' where id = ?'; + $result = DB::update($query, ...$params); + return $result !== false; + } + + public function setActive(int $id, bool $active, ?int $changedBy = null): bool + { + $result = DB::update( + 'update users set active = ?, active_changed_at = NOW(), active_changed_by = ? where id = ?', + $active ? 1 : 0, + $changedBy, + (string) $id + ); + return $result !== false; + } + + public function setCurrentTenant(int $id, int $tenantId): bool + { + $result = DB::update( + 'update users set current_tenant_id = ? where id = ?', + (string) $tenantId, + (string) $id + ); + return $result !== false; + } + + public function setActiveByUuids(array $uuids, bool $active, ?int $modifiedBy = null): bool + { + $uuids = array_values(array_filter(array_map('trim', $uuids))); + if (!$uuids) { + return false; + } + $placeholders = implode(',', array_fill(0, count($uuids), '?')); + $query = "update users set active = ?, modified_by = ?, active_changed_at = NOW(), active_changed_by = ? where uuid in ($placeholders)"; + $params = array_merge([$active ? 1 : 0, $modifiedBy, $modifiedBy], $uuids); + $result = DB::update($query, ...$params); + return $result !== false; + } + + public function setInactiveByIds(array $userIds, ?int $changedBy = null): int + { + $ids = array_values(array_unique(array_filter(array_map('intval', $userIds), static fn ($id) => $id > 0))); + if (!$ids) { + return 0; + } + + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $query = "update users set active = 0, modified_by = ?, active_changed_at = UTC_TIMESTAMP(), active_changed_by = ? where active = 1 and id in ($placeholders)"; + $params = array_merge([$changedBy, $changedBy], array_map('strval', $ids)); + $result = DB::update($query, ...$params); + return $result !== false ? (int) $result : 0; + } + + public function deleteByIds(array $userIds): int + { + $ids = array_values(array_unique(array_filter(array_map('intval', $userIds), static fn ($id) => $id > 0))); + if (!$ids) { + return 0; + } + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $result = DB::delete( + "delete from users where id in ($placeholders)", + ...array_map('strval', $ids) + ); + return $result !== false ? (int) $result : 0; + } + + public function setLocale(int $id, string $locale): bool + { + $result = DB::update('update users set locale = ? where id = ?', $locale, (string) $id); + return $result !== false; + } + + public function setTheme(int $id, string $theme): bool + { + $result = DB::update('update users set theme = ? where id = ?', $theme, (string) $id); + return $result !== false; + } + + public function updateProfileFieldsFromSso(int $id, array $fields): bool + { + if ($id <= 0) { + return false; + } + + $row = DB::selectOne('select first_name, last_name, email, phone, mobile from users where id = ? limit 1', (string) $id); + $existing = $row['users'] ?? null; + if (!is_array($existing)) { + return false; + } + + $allowed = ['first_name', 'last_name', 'phone', 'mobile']; + $updates = []; + foreach ($allowed as $field) { + if (!array_key_exists($field, $fields)) { + continue; + } + $value = trim((string) $fields[$field]); + if ($value === '') { + continue; + } + if ((string) ($existing[$field] ?? '') === $value) { + continue; + } + $updates[$field] = $value; + } + + if (!$updates) { + return true; + } + + if (isset($updates['first_name']) || isset($updates['last_name'])) { + $displayData = [ + 'first_name' => $updates['first_name'] ?? (string) ($existing['first_name'] ?? ''), + 'last_name' => $updates['last_name'] ?? (string) ($existing['last_name'] ?? ''), + 'email' => (string) ($existing['email'] ?? ''), + ]; + $updates['display_name'] = $this->buildDisplayName($displayData); + } + + $setParts = []; + $params = []; + foreach ($updates as $field => $value) { + $setParts[] = sprintf('`%s` = ?', $field); + $params[] = $value; + } + $params[] = (string) $id; + + $result = DB::update( + 'update users set ' . implode(', ', $setParts) . ' where id = ?', + ...$params + ); + + return $result !== false; + } + + public function setPassword(int $id, string $password): bool + { + $hash = password_hash($password, PASSWORD_DEFAULT); + $result = DB::update('update users set password = ?, modified_by = NULL where id = ?', $hash, (string) $id); + return $result !== false; + } + + public function setEmailVerified(int $id): bool + { + $result = DB::update('update users set email_verified_at = NOW() where id = ?', (string) $id); + return $result !== false; + } + + public function delete(int $id): bool + { + $result = DB::delete('delete from users where id = ?', (string) $id); + return $result !== false; + } + + public function deleteByUuids(array $uuids): bool + { + $uuids = array_values(array_filter(array_map('trim', $uuids))); + if (!$uuids) { + return false; + } + $placeholders = implode(',', array_fill(0, count($uuids), '?')); + $query = "delete from users where uuid in ($placeholders)"; + $result = DB::delete($query, ...$uuids); + return $result !== false; + } + + private function buildDisplayName(array $data): string + { + $first = trim((string) ($data['first_name'] ?? '')); + $last = trim((string) ($data['last_name'] ?? '')); + $name = trim($first . ' ' . $last); + if ($name === '') { + $name = trim((string) ($data['email'] ?? '')); + } + return $name; + } +} diff --git a/lib/Service/Access/AccessServicesFactory.php b/lib/Service/Access/AccessServicesFactory.php new file mode 100644 index 0000000..c8e405c --- /dev/null +++ b/lib/Service/Access/AccessServicesFactory.php @@ -0,0 +1,45 @@ +permissionRepository ??= new PermissionRepository(); + } + + public function createRolePermissionRepository(): RolePermissionRepository + { + return $this->rolePermissionRepository ??= new RolePermissionRepository(); + } + + public function createUserRoleRepository(): UserRoleRepository + { + return $this->userRoleRepository ??= new UserRoleRepository(); + } + + public function createPermissionService(): PermissionService + { + return $this->permissionService ??= new PermissionService( + $this->createPermissionRepository(), + $this->createRolePermissionRepository(), + $this->createUserRoleRepository() + ); + } + + public function createPermissionGateway(): PermissionGateway + { + return $this->permissionGateway ??= new PermissionGateway($this->createPermissionService()); + } +} diff --git a/lib/Service/Access/PermissionGateway.php b/lib/Service/Access/PermissionGateway.php new file mode 100644 index 0000000..c35716b --- /dev/null +++ b/lib/Service/Access/PermissionGateway.php @@ -0,0 +1,55 @@ +permissionService->userHas($userId, $permissionKey); + } + + public function getUserPermissions(int $userId, bool $refresh = false): array + { + return $this->permissionService->getUserPermissions($userId, $refresh); + } + + public function getCachedPermissions(int $userId): array + { + return $this->permissionService->getCachedPermissions($userId); + } + + public function clearUserCache(int $userId): void + { + $this->permissionService->clearUserCache($userId); + } + + public function listPaged(array $options): array + { + return $this->permissionService->listPaged($options); + } + + public function find(int $id): ?array + { + return $this->permissionService->find($id); + } + + public function createFromAdmin(array $input): array + { + return $this->permissionService->createFromAdmin($input); + } + + public function updateFromAdmin(int $id, array $input): array + { + return $this->permissionService->updateFromAdmin($id, $input); + } + + public function deleteById(int $id): array + { + return $this->permissionService->deleteById($id); + } +} diff --git a/lib/Service/Access/PermissionService.php b/lib/Service/Access/PermissionService.php index e8c4a51..616e048 100644 --- a/lib/Service/Access/PermissionService.php +++ b/lib/Service/Access/PermissionService.php @@ -2,13 +2,20 @@ namespace MintyPHP\Service\Access; +use MintyPHP\Repository\Access\PermissionRepository; use MintyPHP\Repository\Access\RolePermissionRepository; use MintyPHP\Repository\Access\UserRoleRepository; -use MintyPHP\Repository\Access\PermissionRepository; class PermissionService { - private static array $apiPermissionCache = []; + private array $apiPermissionCache = []; + + public function __construct( + private readonly PermissionRepository $permissionRepository, + private readonly RolePermissionRepository $rolePermissionRepository, + private readonly UserRoleRepository $userRoleRepository + ) { + } public const USERS_CREATE = 'users.create'; public const USERS_DELETE = 'users.delete'; @@ -58,30 +65,30 @@ class PermissionService public const STATS_VIEW = 'stats.view'; public const API_TOKENS_MANAGE = 'api_tokens.manage'; - public static function userHas(int $userId, string $permissionKey): bool + public function userHas(int $userId, string $permissionKey): bool { $permissionKey = trim((string) $permissionKey); if ($userId <= 0 || $permissionKey === '') { return false; } - $keys = self::getUserPermissions($userId); + $keys = $this->getUserPermissions($userId); return in_array($permissionKey, $keys, true); } - public static function getUserPermissions(int $userId, bool $refresh = false): array + public function getUserPermissions(int $userId, bool $refresh = false): array { if ($userId <= 0) { return []; } - if (self::isApiRequest()) { - if (!$refresh && isset(self::$apiPermissionCache[$userId])) { - return self::$apiPermissionCache[$userId]; + if ($this->isApiRequest()) { + if (!$refresh && isset($this->apiPermissionCache[$userId])) { + return $this->apiPermissionCache[$userId]; } - $roleIds = UserRoleRepository::listRoleIdsByUserId($userId); - $keys = RolePermissionRepository::listPermissionKeysByRoleIds($roleIds); - self::$apiPermissionCache[$userId] = $keys; + $roleIds = $this->userRoleRepository->listRoleIdsByUserId($userId); + $keys = $this->rolePermissionRepository->listPermissionKeysByRoleIds($roleIds); + $this->apiPermissionCache[$userId] = $keys; return $keys; } @@ -95,8 +102,8 @@ class PermissionService return $keys; } } - $roleIds = UserRoleRepository::listRoleIdsByUserId($userId); - $keys = RolePermissionRepository::listPermissionKeysByRoleIds($roleIds); + $roleIds = $this->userRoleRepository->listRoleIdsByUserId($userId); + $keys = $this->rolePermissionRepository->listPermissionKeysByRoleIds($roleIds); $_SESSION['permissions'] = [ 'user_id' => $userId, 'keys' => $keys, @@ -104,14 +111,14 @@ class PermissionService return $keys; } - public static function getCachedPermissions(int $userId): array + public function getCachedPermissions(int $userId): array { if ($userId <= 0) { return []; } - if (self::isApiRequest()) { - return self::$apiPermissionCache[$userId] ?? []; + if ($this->isApiRequest()) { + return $this->apiPermissionCache[$userId] ?? []; } $cache = $_SESSION['permissions'] ?? null; @@ -122,10 +129,10 @@ class PermissionService return []; } - public static function clearUserCache(int $userId): void + public function clearUserCache(int $userId): void { - if (self::isApiRequest()) { - unset(self::$apiPermissionCache[$userId]); + if ($this->isApiRequest()) { + unset($this->apiPermissionCache[$userId]); return; } @@ -135,98 +142,98 @@ class PermissionService } } - private static function isApiRequest(): bool + private function isApiRequest(): bool { return defined('MINTY_API_REQUEST'); } - public static function list(): array + public function list(): array { - return PermissionRepository::list(); + return $this->permissionRepository->list(); } - public static function listActive(): array + public function listActive(): array { - return PermissionRepository::listActive(); + return $this->permissionRepository->listActive(); } - public static function listPaged(array $options): array + public function listPaged(array $options): array { - return PermissionRepository::listPaged($options); + return $this->permissionRepository->listPaged($options); } - public static function find(int $id): ?array + public function find(int $id): ?array { - return PermissionRepository::find($id); + return $this->permissionRepository->find($id); } - public static function findByKey(string $key): ?array + public function findByKey(string $key): ?array { - return PermissionRepository::findByKey($key); + return $this->permissionRepository->findByKey($key); } - public static function createFromAdmin(array $input): array + public function createFromAdmin(array $input): array { - $form = self::sanitizeBase($input); - $errors = self::validateBase($form); + $form = $this->sanitizeBase($input); + $errors = $this->validateBase($form); if ($errors) { return ['ok' => false, 'errors' => $errors, 'form' => $form]; } - if (PermissionRepository::findByKey($form['key'])) { + if ($this->permissionRepository->findByKey($form['key'])) { return ['ok' => false, 'errors' => [t('Permission key already exists')], 'form' => $form]; } - $createdId = PermissionRepository::create($form); + $createdId = $this->permissionRepository->create($form); if (!$createdId) { return ['ok' => false, 'errors' => [t('Permission can not be created')], 'form' => $form]; } return ['ok' => true, 'form' => $form, 'id' => (int) $createdId]; } - public static function updateFromAdmin(int $id, array $input): array + public function updateFromAdmin(int $id, array $input): array { - $form = self::sanitizeBase($input); - $errors = self::validateBase($form); + $form = $this->sanitizeBase($input); + $errors = $this->validateBase($form); if ($errors) { return ['ok' => false, 'errors' => $errors, 'form' => $form]; } - $existing = PermissionRepository::findByKey($form['key']); + $existing = $this->permissionRepository->findByKey($form['key']); if ($existing && (int) ($existing['id'] ?? 0) !== $id) { return ['ok' => false, 'errors' => [t('Permission key already exists')], 'form' => $form]; } - $updated = PermissionRepository::update($id, $form); + $updated = $this->permissionRepository->update($id, $form); if (!$updated) { return ['ok' => false, 'errors' => [t('Permission can not be updated')], 'form' => $form]; } return ['ok' => true, 'form' => $form]; } - public static function deleteById(int $id): array + public function deleteById(int $id): array { - $permission = PermissionRepository::find($id); + $permission = $this->permissionRepository->find($id); if (!$permission) { return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } if ((int) ($permission['is_system'] ?? 0) === 1) { return ['ok' => false, 'status' => 403, 'error' => 'system_permission_protected']; } - $deleted = PermissionRepository::delete($id); + $deleted = $this->permissionRepository->delete($id); if (!$deleted) { return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; } return ['ok' => true, 'permission' => $permission]; } - private static function sanitizeBase(array $input): array + private function sanitizeBase(array $input): array { return [ 'key' => trim((string) ($input['key'] ?? '')), 'description' => trim((string) ($input['description'] ?? '')), - 'active' => self::normalizeActive($input['active'] ?? 1), - 'is_system' => self::normalizeFlag($input['is_system'] ?? 0), + 'active' => $this->normalizeActive($input['active'] ?? 1), + 'is_system' => $this->normalizeFlag($input['is_system'] ?? 0), ]; } - private static function validateBase(array $form): array + private function validateBase(array $form): array { $errors = []; if ($form['key'] === '') { @@ -237,7 +244,7 @@ class PermissionService return $errors; } - private static function normalizeActive($value): int + private function normalizeActive($value): int { $value = strtolower(trim((string) $value)); if (in_array($value, ['0', 'false', 'inactive'], true)) { @@ -246,7 +253,7 @@ class PermissionService return 1; } - private static function normalizeFlag($value): int + private function normalizeFlag($value): int { if (is_bool($value)) { return $value ? 1 : 0; diff --git a/lib/Service/Access/RoleService.php b/lib/Service/Access/RoleService.php index c4c453a..3a8d389 100644 --- a/lib/Service/Access/RoleService.php +++ b/lib/Service/Access/RoleService.php @@ -3,45 +3,52 @@ namespace MintyPHP\Service\Access; use MintyPHP\Repository\Access\RoleRepository; -use MintyPHP\Service\Settings\SettingService; +use MintyPHP\Service\Directory\DirectorySettingsGateway; +use MintyPHP\Service\Settings\SettingServicesFactory; class RoleService { - public static function list(): array - { - return RoleRepository::list(); + public function __construct( + private readonly ?RoleRepository $roleRepository = null, + private readonly ?DirectorySettingsGateway $settingsGateway = null + ) { } - public static function listActive(): array + public function list(): array { - return RoleRepository::listActive(); + return $this->roleRepository()->list(); } - public static function listPaged(array $options): array + public function listActive(): array { - return RoleRepository::listPaged($options); + return $this->roleRepository()->listActive(); } - public static function findByUuid(string $uuid): ?array + public function listPaged(array $options): array { - return RoleRepository::findByUuid($uuid); + return $this->roleRepository()->listPaged($options); } - public static function findById(int $id): ?array + public function findByUuid(string $uuid): ?array { - return RoleRepository::find($id); + return $this->roleRepository()->findByUuid($uuid); } - public static function createFromAdmin(array $input, int $currentUserId = 0): array + public function findById(int $id): ?array { - $form = self::sanitizeBase($input); - $errors = self::validateBase($form, 0); + return $this->roleRepository()->find($id); + } + + public function createFromAdmin(array $input, int $currentUserId = 0): array + { + $form = $this->sanitizeBase($input); + $errors = $this->validateBase($form, 0); if ($errors) { return ['ok' => false, 'errors' => $errors, 'form' => $form]; } - $createdId = RoleRepository::create([ + $createdId = $this->roleRepository()->create([ 'description' => $form['description'], 'code' => $form['code'] ?: null, 'active' => $form['active'], @@ -52,24 +59,24 @@ class RoleService return ['ok' => false, 'errors' => [t('Role can not be created')], 'form' => $form]; } - $createdRole = RoleRepository::find((int) $createdId); + $createdRole = $this->roleRepository()->find((int) $createdId); $uuid = $createdRole['uuid'] ?? null; if (!empty($input['is_default'])) { - SettingService::setDefaultRoleId((int) $createdId); + $this->settingsGateway()->setDefaultRoleId((int) $createdId); } return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId]; } - public static function updateFromAdmin(int $roleId, array $input, int $currentUserId = 0): array + public function updateFromAdmin(int $roleId, array $input, int $currentUserId = 0): array { - $form = self::sanitizeBase($input); - $errors = self::validateBase($form, $roleId); + $form = $this->sanitizeBase($input); + $errors = $this->validateBase($form, $roleId); if ($errors) { return ['ok' => false, 'errors' => $errors, 'form' => $form]; } - $updated = RoleRepository::update($roleId, [ + $updated = $this->roleRepository()->update($roleId, [ 'description' => $form['description'], 'code' => $form['code'] ?: null, 'active' => $form['active'], @@ -83,14 +90,14 @@ class RoleService return ['ok' => true, 'form' => $form]; } - public static function deleteByUuid(string $uuid): array + public function deleteByUuid(string $uuid): array { $uuid = trim($uuid); if ($uuid === '') { return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } - $role = RoleRepository::findByUuid($uuid); + $role = $this->roleRepository()->findByUuid($uuid); if (!$role || !isset($role['id'])) { return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } @@ -99,7 +106,7 @@ class RoleService return ['ok' => false, 'status' => 403, 'error' => 'admin_role_protected']; } - $deleted = RoleRepository::delete((int) $role['id']); + $deleted = $this->roleRepository()->delete((int) $role['id']); if (!$deleted) { return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; } @@ -107,29 +114,29 @@ class RoleService return ['ok' => true, 'role' => $role]; } - private static function sanitizeBase(array $input): array + private function sanitizeBase(array $input): array { return [ 'description' => trim((string) ($input['description'] ?? '')), 'code' => trim((string) ($input['code'] ?? '')), - 'active' => self::normalizeActive($input['active'] ?? 1), + 'active' => $this->normalizeActive($input['active'] ?? 1), ]; } - private static function validateBase(array $form, int $excludeId): array + private function validateBase(array $form, int $excludeId): array { $errors = []; if ($form['description'] === '') { $errors[] = t('Description cannot be empty'); } $code = $form['code'] ?? ''; - if ($code !== '' && RoleRepository::existsByCode($code, $excludeId)) { + if ($code !== '' && $this->roleRepository()->existsByCode($code, $excludeId)) { $errors[] = t('Role code already exists'); } return $errors; } - private static function normalizeActive($value): int + private function normalizeActive($value): int { $value = strtolower(trim((string) $value)); if (in_array($value, ['0', 'false', 'inactive'], true)) { @@ -137,4 +144,20 @@ class RoleService } return 1; } + + private function roleRepository(): RoleRepository + { + return $this->roleRepository ?? new RoleRepository(); + } + + private function settingsGateway(): DirectorySettingsGateway + { + if ($this->settingsGateway instanceof DirectorySettingsGateway) { + return $this->settingsGateway; + } + + return new DirectorySettingsGateway( + (new SettingServicesFactory())->createSettingGateway() + ); + } } diff --git a/lib/Service/AddressBook/AddressBookAvatarGateway.php b/lib/Service/AddressBook/AddressBookAvatarGateway.php new file mode 100644 index 0000000..62053aa --- /dev/null +++ b/lib/Service/AddressBook/AddressBookAvatarGateway.php @@ -0,0 +1,17 @@ +userAvatarService->hasAvatar($userUuid); + } +} diff --git a/lib/Service/AddressBook/AddressBookCustomFieldGateway.php b/lib/Service/AddressBook/AddressBookCustomFieldGateway.php new file mode 100644 index 0000000..389683d --- /dev/null +++ b/lib/Service/AddressBook/AddressBookCustomFieldGateway.php @@ -0,0 +1,19 @@ +tenantService()->list(); + } + + public function listDepartmentsByTenantIds(array $tenantIds): array + { + return $this->departmentService()->listByTenantIds($tenantIds); + } + + public function listDepartments(): array + { + return $this->departmentService()->list(); + } + + public function listActiveRoles(): array + { + return $this->roleService()->listActive(); + } + + public function getUserTenantIds(int $userId): array + { + return $this->scopeGateway()->getUserTenantIds($userId); + } + + public function isStrictScope(): bool + { + return $this->scopeGateway()->isStrict(); + } + + public function canAccessUser(int $viewerUserId, int $targetUserId): bool + { + return $this->scopeGateway()->canAccess('users', $targetUserId, $viewerUserId); + } + + private function tenantService(): TenantService + { + return $this->tenantService ?? new TenantService(); + } + + private function departmentService(): DepartmentService + { + return $this->departmentService ?? new DepartmentService(); + } + + private function roleService(): RoleService + { + return $this->roleService ?? new RoleService(); + } + + private function scopeGateway(): DirectoryScopeGateway + { + return $this->scopeGateway ?? new DirectoryScopeGateway(); + } +} diff --git a/lib/Service/AddressBook/AddressBookService.php b/lib/Service/AddressBook/AddressBookService.php new file mode 100644 index 0000000..0b9f3df --- /dev/null +++ b/lib/Service/AddressBook/AddressBookService.php @@ -0,0 +1,349 @@ +splitCommaValues($query['tenants'] ?? ''); + $activeRoles = $this->splitCommaValues($query['roles'] ?? ''); + $activeDepartments = $this->splitCommaValues($query['departments'] ?? ''); + + $tenantIds = $this->normalizeIds($this->directoryGateway->getUserTenantIds($currentUserId)); + $tenants = $this->directoryGateway->listTenants(); + if ($tenantIds) { + $allowedTenantMap = array_fill_keys($tenantIds, true); + $tenants = array_values(array_filter( + $tenants, + static function (array $tenant) use ($allowedTenantMap): bool { + $tenantId = (int) ($tenant['id'] ?? 0); + return $tenantId > 0 && isset($allowedTenantMap[$tenantId]); + } + )); + } elseif ($this->directoryGateway->isStrictScope()) { + $tenants = []; + } + + $tenantItems = array_map( + static fn (array $tenant): array => [ + 'id' => (string) ($tenant['uuid'] ?? ''), + 'description' => (string) ($tenant['description'] ?? ''), + ], + $tenants + ); + + $departments = $tenantIds + ? $this->directoryGateway->listDepartmentsByTenantIds($tenantIds) + : ($this->directoryGateway->isStrictScope() ? [] : $this->directoryGateway->listDepartments()); + + $roles = $this->directoryGateway->listActiveRoles(); + + $customFieldFilterSpec = $this->customFieldGateway->extractFilterSpec($query, $currentUserId); + $customFieldFilterDefinitions = $this->normalizeDefinitions( + $customFieldFilterSpec['definitions'] ?? [] + ); + $customFieldFilterQuery = is_array($customFieldFilterSpec['query'] ?? null) + ? $customFieldFilterSpec['query'] + : []; + + $customFieldDefinitionIds = []; + foreach ($customFieldFilterDefinitions as $definition) { + $definitionId = (int) ($definition['id'] ?? 0); + if ($definitionId > 0) { + $customFieldDefinitionIds[] = $definitionId; + } + } + $customFieldDefinitionIds = array_values(array_unique($customFieldDefinitionIds)); + + $customFieldOptions = $this->customFieldGateway->listOptionsByDefinitionIds( + $customFieldDefinitionIds, + true + ); + + $customFieldOptionsByDefinition = []; + foreach ($customFieldOptions as $option) { + $definitionId = (int) ($option['definition_id'] ?? 0); + if ($definitionId <= 0) { + continue; + } + $customFieldOptionsByDefinition[$definitionId] ??= []; + $customFieldOptionsByDefinition[$definitionId][] = [ + 'id' => (string) ((int) ($option['id'] ?? 0)), + 'description' => (string) ($option['label'] ?? ''), + ]; + } + + $tenantLabelById = []; + foreach ($tenants as $tenant) { + $tenantId = (int) ($tenant['id'] ?? 0); + if ($tenantId <= 0) { + continue; + } + $tenantLabelById[$tenantId] = (string) ($tenant['description'] ?? ''); + } + + foreach ($customFieldFilterDefinitions as &$definition) { + $definitionId = (int) ($definition['id'] ?? 0); + $tenantId = (int) ($definition['tenant_id'] ?? 0); + $definition['options'] = $customFieldOptionsByDefinition[$definitionId] ?? []; + $definition['tenant_label'] = $tenantLabelById[$tenantId] ?? ''; + } + unset($definition); + + $tenantDepartmentMap = []; + if ($tenantIds) { + $departmentMapByTenantId = []; + foreach ($departments as $department) { + $departmentId = (int) ($department['id'] ?? 0); + $departmentTenantId = (int) ($department['tenant_id'] ?? 0); + if ($departmentId <= 0 || $departmentTenantId <= 0) { + continue; + } + $departmentMapByTenantId[$departmentTenantId] ??= []; + $departmentMapByTenantId[$departmentTenantId][] = $departmentId; + } + + foreach ($tenants as $tenant) { + $tenantId = (int) ($tenant['id'] ?? 0); + $tenantUuid = (string) ($tenant['uuid'] ?? ''); + if ($tenantId > 0 && $tenantUuid !== '') { + $tenantDepartmentMap[$tenantUuid] = array_map( + 'strval', + $departmentMapByTenantId[$tenantId] ?? [] + ); + } + } + } + + return [ + 'activeTenants' => $activeTenants, + 'activeRoles' => $activeRoles, + 'activeDepartments' => $activeDepartments, + 'tenantItems' => $tenantItems, + 'departments' => $departments, + 'roles' => $roles, + 'customFieldFilterDefinitions' => $customFieldFilterDefinitions, + 'customFieldFilterQuery' => $customFieldFilterQuery, + 'tenantDepartmentMap' => $tenantDepartmentMap, + ]; + } + + public function buildGridResult(int $currentUserId, array $query): array + { + $limit = (int) ($query['limit'] ?? 10); + $offset = (int) ($query['offset'] ?? 0); + $search = trim((string) ($query['search'] ?? '')); + $order = (string) ($query['order'] ?? 'last_name'); + $dir = (string) ($query['dir'] ?? 'asc'); + $tenant = trim((string) ($query['tenant'] ?? '')); + $tenants = $query['tenants'] ?? ''; + $departments = $query['departments'] ?? ''; + $roles = $query['roles'] ?? ''; + $customFieldFilterSpec = $this->customFieldGateway->extractFilterSpec($query, $currentUserId); + + $result = $this->userAccountService->listPaged([ + 'limit' => $limit, + 'offset' => $offset, + 'search' => $search, + 'order' => $order, + 'dir' => $dir, + 'tenant' => $tenant, + 'tenants' => $tenants, + 'departments' => $departments, + 'roles' => $roles, + 'customFieldFilterSpec' => $customFieldFilterSpec, + 'tenantUserId' => $currentUserId, + 'active' => 'active', + ]); + + $rows = []; + foreach (($result['rows'] ?? []) as $row) { + $tenantList = $this->normalizeLabelList($row['tenant_labels'] ?? []); + $departmentList = $this->normalizeLabelList($row['department_labels'] ?? []); + $roleList = $this->normalizeLabelList($row['role_labels'] ?? []); + + $uuid = (string) ($row['uuid'] ?? ''); + $displayName = trim((string) ($row['display_name'] ?? '')); + if ($displayName === '') { + $displayName = trim((string) ($row['first_name'] ?? '') . ' ' . (string) ($row['last_name'] ?? '')); + } + if ($displayName === '') { + $displayName = (string) ($row['email'] ?? ''); + } + + $rows[] = [ + 'uuid' => $uuid, + 'display_name' => $displayName, + 'first_name' => (string) ($row['first_name'] ?? ''), + 'last_name' => (string) ($row['last_name'] ?? ''), + 'email' => (string) ($row['email'] ?? ''), + 'phone' => (string) ($row['phone'] ?? ''), + 'mobile' => (string) ($row['mobile'] ?? ''), + 'short_dial' => (string) ($row['short_dial'] ?? ''), + 'tenants' => $tenantList, + 'departments' => $departmentList, + 'roles' => $roleList, + 'has_avatar' => $uuid !== '' && $this->avatarGateway->hasAvatar($uuid), + ]; + } + + return [ + 'data' => $rows, + 'total' => (int) ($result['total'] ?? 0), + ]; + } + + public function buildViewContext(int $currentUserId, string $uuid): array + { + $uuid = trim($uuid); + if ($uuid === '') { + return ['status' => 'invalid_uuid']; + } + + $user = $this->userAccountService->findByUuid($uuid); + if (!$user || !isset($user['id'])) { + return ['status' => 'not_found']; + } + + $targetUserId = (int) $user['id']; + if ($targetUserId <= 0) { + return ['status' => 'not_found']; + } + + if ( + $currentUserId !== $targetUserId + && !$this->directoryGateway->canAccessUser($currentUserId, $targetUserId) + ) { + return ['status' => 'forbidden']; + } + + $viewerTenantIds = $this->normalizeIds($this->directoryGateway->getUserTenantIds($currentUserId)); + $assignments = $this->userAssignmentService->buildAssignmentsForUser($targetUserId); + + $departmentMap = []; + foreach (($assignments['departments'] ?? []) as $department) { + if (empty($department['active'])) { + continue; + } + $tenantId = (int) ($department['tenant_id'] ?? 0); + $label = trim((string) ($department['description'] ?? '')); + if ($tenantId <= 0 || $label === '' || !in_array($tenantId, $viewerTenantIds, true)) { + continue; + } + $departmentMap[$tenantId] ??= []; + $departmentMap[$tenantId][] = $label; + } + foreach ($departmentMap as $tenantId => $labels) { + $labels = array_values(array_unique($labels)); + natcasesort($labels); + $departmentMap[$tenantId] = array_values($labels); + } + + $primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0); + $tenantGroups = []; + foreach (($assignments['tenants'] ?? []) as $tenant) { + $tenantId = (int) ($tenant['id'] ?? 0); + $tenantLabel = trim((string) ($tenant['description'] ?? '')); + $status = strtolower(trim((string) ($tenant['status'] ?? ''))); + if ( + $tenantId <= 0 + || $tenantLabel === '' + || $status !== 'active' + || !in_array($tenantId, $viewerTenantIds, true) + ) { + continue; + } + $tenantGroups[] = [ + 'label' => $tenantLabel, + 'is_primary' => $primaryTenantId > 0 && $tenantId === $primaryTenantId, + 'departments' => $departmentMap[$tenantId] ?? [], + ]; + } + + usort($tenantGroups, static fn (array $a, array $b): int => strnatcasecmp( + (string) $a['label'], + (string) $b['label'] + )); + + $roleLabels = []; + foreach (($assignments['roles'] ?? []) as $role) { + if (empty($role['active'])) { + continue; + } + $label = trim((string) ($role['description'] ?? '')); + if ($label !== '') { + $roleLabels[] = $label; + } + } + $roleLabels = array_values(array_unique($roleLabels)); + natcasesort($roleLabels); + $roleLabels = array_values($roleLabels); + + $avatarUuid = (string) ($user['uuid'] ?? ''); + $user['has_avatar'] = $avatarUuid !== '' && $this->avatarGateway->hasAvatar($avatarUuid); + $user['tenant_groups'] = $tenantGroups; + $user['role_labels'] = $roleLabels; + + return [ + 'status' => 'ok', + 'user' => $user, + ]; + } + + private function splitCommaValues($value): array + { + return array_values(array_filter( + array_map('trim', explode(',', (string) $value)), + static fn (string $item): bool => $item !== '' + )); + } + + private function normalizeDefinitions($definitions): array + { + if (!is_array($definitions)) { + return []; + } + return array_values(array_filter( + $definitions, + static fn ($definition): bool => is_array($definition) + )); + } + + private function normalizeIds(array $values): array + { + $ids = array_values(array_unique(array_map('intval', $values))); + return array_values(array_filter($ids, static fn (int $id): bool => $id > 0)); + } + + private function normalizeLabelList($value): array + { + if (is_string($value)) { + if ($value === '') { + return []; + } + $items = explode('||', $value); + } elseif (is_array($value)) { + $items = $value; + } else { + return []; + } + + return array_values(array_filter(array_map( + static fn ($item): string => trim((string) $item), + $items + ), static fn (string $item): bool => $item !== '')); + } +} diff --git a/lib/Service/AddressBook/AddressBookServicesFactory.php b/lib/Service/AddressBook/AddressBookServicesFactory.php new file mode 100644 index 0000000..dda601e --- /dev/null +++ b/lib/Service/AddressBook/AddressBookServicesFactory.php @@ -0,0 +1,69 @@ +addressBookService !== null) { + return $this->addressBookService; + } + + $userFactory = $this->userServicesFactory(); + return $this->addressBookService = new AddressBookService( + $userFactory->createUserAccountService(), + $userFactory->createUserAssignmentService(), + $this->createAddressBookDirectoryGateway(), + $this->createAddressBookCustomFieldGateway(), + $this->createAddressBookAvatarGateway() + ); + } + + public function createAddressBookDirectoryGateway(): AddressBookDirectoryGateway + { + if ($this->addressBookDirectoryGateway !== null) { + return $this->addressBookDirectoryGateway; + } + + $directoryFactory = $this->directoryServicesFactory(); + return $this->addressBookDirectoryGateway = new AddressBookDirectoryGateway( + $directoryFactory->createTenantService(), + $directoryFactory->createDepartmentService(), + $directoryFactory->createRoleService(), + $directoryFactory->createDirectoryScopeGateway() + ); + } + + public function createAddressBookCustomFieldGateway(): AddressBookCustomFieldGateway + { + return $this->addressBookCustomFieldGateway ??= new AddressBookCustomFieldGateway(); + } + + public function createAddressBookAvatarGateway(): AddressBookAvatarGateway + { + return $this->addressBookAvatarGateway ??= new AddressBookAvatarGateway( + $this->userServicesFactory()->createUserAvatarService() + ); + } + + private function userServicesFactory(): UserServicesFactory + { + return $this->userServicesFactory ??= new UserServicesFactory(); + } + + private function directoryServicesFactory(): DirectoryServicesFactory + { + return $this->directoryServicesFactory ??= new DirectoryServicesFactory(); + } +} diff --git a/lib/Service/Audit/ApiAuditService.php b/lib/Service/Audit/ApiAuditService.php index 813282d..dde4366 100644 --- a/lib/Service/Audit/ApiAuditService.php +++ b/lib/Service/Audit/ApiAuditService.php @@ -13,55 +13,59 @@ class ApiAuditService private const MAX_STRING_LENGTH = 500; private const MAX_ARRAY_ITEMS = 30; - private static ?array $context = null; + private ?array $context = null; - public static function startRequestContext(): void + public function __construct(private readonly ApiAuditLogRepository $apiAuditLogRepository) { - if (self::$context !== null) { + } + + public function startRequestContext(): void + { + if ($this->context !== null) { return; } $method = strtoupper(trim((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'))); if ($method === 'OPTIONS') { - self::$context = ['active' => false, 'finished' => true]; + $this->context = ['active' => false, 'finished' => true]; return; } $path = parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH); $path = is_string($path) ? trim($path) : ''; if ($path === '') { - self::$context = ['active' => false, 'finished' => true]; + $this->context = ['active' => false, 'finished' => true]; return; } - self::$context = [ + $this->context = [ 'active' => true, 'finished' => false, 'started_at' => microtime(true), 'request_id' => RepoQuery::uuidV4(), 'method' => substr($method !== '' ? $method : 'GET', 0, 8), 'path' => substr($path, 0, 255), - 'query_json' => self::buildRedactedQueryJson($_GET), + 'query_json' => $this->buildRedactedQueryJson($_GET), ]; } - public static function finish(int $statusCode, ?string $errorCode = null): void + public function finish(int $statusCode, ?string $errorCode = null): void { - if (!is_array(self::$context)) { + if (!is_array($this->context)) { return; } - if (!empty(self::$context['finished'])) { + if (!empty($this->context['finished'])) { return; } - self::$context['finished'] = true; + $this->context['finished'] = true; - if (empty(self::$context['active'])) { + if (empty($this->context['active'])) { return; } $durationMs = null; - if (isset(self::$context['started_at'])) { - $durationMs = (int) round((microtime(true) - (float) self::$context['started_at']) * 1000); + if (isset($this->context['started_at'])) { + $durationMs = (int) round((microtime(true) - (float) $this->context['started_at']) * 1000); if ($durationMs < 0) { $durationMs = 0; } @@ -95,10 +99,10 @@ class ApiAuditService $tokenTenantId = ApiAuth::isAuthenticated() ? (int) (ApiAuth::scopedTenantId() ?? 0) : 0; $payload = [ - 'request_id' => (string) (self::$context['request_id'] ?? RepoQuery::uuidV4()), - 'method' => (string) (self::$context['method'] ?? ''), - 'path' => (string) (self::$context['path'] ?? ''), - 'query_json' => self::$context['query_json'] ?? null, + 'request_id' => (string) ($this->context['request_id'] ?? RepoQuery::uuidV4()), + 'method' => (string) ($this->context['method'] ?? ''), + 'path' => (string) ($this->context['path'] ?? ''), + 'query_json' => $this->context['query_json'] ?? null, 'status_code' => $statusCode, 'duration_ms' => $durationMs, 'error_code' => $normalizedErrorCode, @@ -111,33 +115,33 @@ class ApiAuditService ]; try { - ApiAuditLogRepository::create($payload); + $this->apiAuditLogRepository->create($payload); } catch (\Throwable $exception) { // Never break API responses because of audit logging failures. } } - public static function listPaged(array $filters): array + public function listPaged(array $filters): array { - return ApiAuditLogRepository::listPaged($filters); + return $this->apiAuditLogRepository->listPaged($filters); } - public static function find(int $id): ?array + public function find(int $id): ?array { - return ApiAuditLogRepository::find($id); + return $this->apiAuditLogRepository->find($id); } - public static function purgeExpired(): int + public function purgeExpired(): int { - return ApiAuditLogRepository::purgeOlderThanDays(self::RETENTION_DAYS); + return $this->apiAuditLogRepository->purgeOlderThanDays(self::RETENTION_DAYS); } - private static function buildRedactedQueryJson(array $query): ?string + private function buildRedactedQueryJson(array $query): ?string { if (!$query) { return null; } - $redacted = self::redactArray($query); + $redacted = $this->redactArray($query); if ($redacted === []) { return null; } @@ -145,7 +149,7 @@ class ApiAuditService return is_string($encoded) ? $encoded : null; } - private static function redactArray(array $data): array + private function redactArray(array $data): array { $result = []; $i = 0; @@ -158,20 +162,20 @@ class ApiAuditService continue; } - if (self::isSensitiveKey($key)) { + if ($this->isSensitiveKey($key)) { $result[$key] = '[REDACTED]'; } else { - $result[$key] = self::normalizeValue($value); + $result[$key] = $this->normalizeValue($value); } $i++; } return $result; } - private static function normalizeValue(mixed $value): mixed + private function normalizeValue(mixed $value): mixed { if (is_array($value)) { - return self::redactArray($value); + return $this->redactArray($value); } if (is_bool($value) || is_int($value) || is_float($value) || $value === null) { @@ -188,7 +192,7 @@ class ApiAuditService return $string; } - private static function isSensitiveKey(string $key): bool + private function isSensitiveKey(string $key): bool { $key = strtolower(trim($key)); if ($key === '') { @@ -216,5 +220,4 @@ class ApiAuditService || str_contains($key, 'authorization') || str_ends_with($key, '_key'); } - } diff --git a/lib/Service/Audit/AuditServicesFactory.php b/lib/Service/Audit/AuditServicesFactory.php new file mode 100644 index 0000000..b8ac05e --- /dev/null +++ b/lib/Service/Audit/AuditServicesFactory.php @@ -0,0 +1,36 @@ +apiAuditLogRepository ??= new ApiAuditLogRepository(); + } + + public function createUserLifecycleAuditRepository(): UserLifecycleAuditRepository + { + return $this->userLifecycleAuditRepository ??= new UserLifecycleAuditRepository(); + } + + public function createApiAuditService(): ApiAuditService + { + return $this->apiAuditService ??= new ApiAuditService($this->createApiAuditLogRepository()); + } + + public function createUserLifecycleAuditService(): UserLifecycleAuditService + { + return $this->userLifecycleAuditService ??= new UserLifecycleAuditService( + $this->createUserLifecycleAuditRepository() + ); + } +} diff --git a/lib/Service/Audit/ImportAuditService.php b/lib/Service/Audit/ImportAuditService.php index d16aaac..b374d7c 100644 --- a/lib/Service/Audit/ImportAuditService.php +++ b/lib/Service/Audit/ImportAuditService.php @@ -12,9 +12,13 @@ class ImportAuditService /** * @var array */ - private static array $startedAtByRunId = []; + private array $startedAtByRunId = []; - public static function startRun( + public function __construct(private readonly ImportAuditRunRepository $importAuditRunRepository) + { + } + + public function startRun( string $profileKey, array $mappedTargets, ?string $sourceFilename, @@ -27,12 +31,12 @@ class ImportAuditService } try { - $runId = ImportAuditRunRepository::createRunning([ + $runId = $this->importAuditRunRepository->createRunning([ 'run_uuid' => RepoQuery::uuidV4(), 'profile_key' => $profileKey, 'status' => 'running', - 'source_filename' => self::normalizeSourceFilename($sourceFilename), - 'mapped_targets_csv' => self::normalizeMappedTargetsCsv($mappedTargets), + 'source_filename' => $this->normalizeSourceFilename($sourceFilename), + 'mapped_targets_csv' => $this->normalizeMappedTargetsCsv($mappedTargets), 'user_id' => $userId > 0 ? $userId : null, 'current_tenant_id' => ($currentTenantId ?? 0) > 0 ? (int) $currentTenantId : null, ]); @@ -44,11 +48,11 @@ class ImportAuditService return null; } - self::$startedAtByRunId[(int) $runId] = microtime(true); + $this->startedAtByRunId[(int) $runId] = microtime(true); return (int) $runId; } - public static function finishRun(?int $runId, array $result, ?string $forcedStatus = null): void + public function finishRun(?int $runId, array $result, ?string $forcedStatus = null): void { $runId = (int) ($runId ?? 0); if ($runId <= 0) { @@ -60,7 +64,7 @@ class ImportAuditService $skippedCount = max(0, (int) ($result['skipped'] ?? 0)); $failedCount = max(0, (int) ($result['failed'] ?? 0)); - $status = self::normalizeStatus($forcedStatus); + $status = $this->normalizeStatus($forcedStatus); if ($status === null) { if (array_key_exists('ok', $result) && !($result['ok'] ?? false)) { $status = 'failed'; @@ -74,18 +78,18 @@ class ImportAuditService } $durationMs = null; - if (isset(self::$startedAtByRunId[$runId])) { - $durationMs = (int) round((microtime(true) - self::$startedAtByRunId[$runId]) * 1000); + if (isset($this->startedAtByRunId[$runId])) { + $durationMs = (int) round((microtime(true) - $this->startedAtByRunId[$runId]) * 1000); if ($durationMs < 0) { $durationMs = 0; } - unset(self::$startedAtByRunId[$runId]); + unset($this->startedAtByRunId[$runId]); } - $errorCodesJson = self::encodeErrorCounts($result); + $errorCodesJson = $this->encodeErrorCounts($result); try { - ImportAuditRunRepository::finishById($runId, [ + $this->importAuditRunRepository->finishById($runId, [ 'status' => $status, 'rows_total' => $rowsTotal, 'created_count' => $createdCount, @@ -99,25 +103,25 @@ class ImportAuditService } } - public static function listPaged(array $filters): array + public function listPaged(array $filters): array { - return ImportAuditRunRepository::listPaged($filters); + return $this->importAuditRunRepository->listPaged($filters); } - public static function find(int $id): ?array + public function find(int $id): ?array { - return ImportAuditRunRepository::find($id); + return $this->importAuditRunRepository->find($id); } - public static function purgeExpired(): int + public function purgeExpired(): int { - return ImportAuditRunRepository::purgeOlderThanDays(self::RETENTION_DAYS); + return $this->importAuditRunRepository->purgeOlderThanDays(self::RETENTION_DAYS); } /** * @param array $mappedTargets */ - private static function normalizeMappedTargetsCsv(array $mappedTargets): ?string + private function normalizeMappedTargetsCsv(array $mappedTargets): ?string { $normalized = []; foreach ($mappedTargets as $target) { @@ -135,7 +139,7 @@ class ImportAuditService return implode(',', $list); } - private static function normalizeSourceFilename(?string $sourceFilename): ?string + private function normalizeSourceFilename(?string $sourceFilename): ?string { $sourceFilename = trim((string) ($sourceFilename ?? '')); if ($sourceFilename === '') { @@ -150,7 +154,7 @@ class ImportAuditService return strlen($base) > 255 ? substr($base, 0, 255) : $base; } - private static function normalizeStatus(?string $status): ?string + private function normalizeStatus(?string $status): ?string { $status = strtolower(trim((string) ($status ?? ''))); if ($status === '') { @@ -159,7 +163,7 @@ class ImportAuditService return in_array($status, ['running', 'success', 'partial', 'failed'], true) ? $status : null; } - private static function encodeErrorCounts(array $result): ?string + private function encodeErrorCounts(array $result): ?string { $counts = []; @@ -200,3 +204,4 @@ class ImportAuditService return is_string($encoded) ? $encoded : null; } } + diff --git a/lib/Service/Audit/UserLifecycleAuditService.php b/lib/Service/Audit/UserLifecycleAuditService.php index 3f196b7..73f065f 100644 --- a/lib/Service/Audit/UserLifecycleAuditService.php +++ b/lib/Service/Audit/UserLifecycleAuditService.php @@ -39,7 +39,11 @@ class UserLifecycleAuditService 'active_changed_at', ]; - public static function logDeactivate( + public function __construct(private readonly UserLifecycleAuditRepository $userLifecycleAuditRepository) + { + } + + public function logDeactivate( string $runUuid, string $triggerType, array $policy, @@ -49,7 +53,7 @@ class UserLifecycleAuditService ?string $reasonCode = null ): bool { try { - return UserLifecycleAuditRepository::create(self::buildBaseRow( + return $this->userLifecycleAuditRepository->create($this->buildBaseRow( $runUuid, 'deactivate', $triggerType, @@ -64,7 +68,7 @@ class UserLifecycleAuditService } } - public static function logDeleteWithSnapshot( + public function logDeleteWithSnapshot( string $runUuid, string $triggerType, array $policy, @@ -72,12 +76,12 @@ class UserLifecycleAuditService array $targetUser ): int|false { try { - $snapshot = self::buildSnapshot($targetUser); + $snapshot = $this->buildSnapshot($targetUser); $json = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - if (!is_string($json) || $json === '') { + if (!is_string($json)) { return false; } - $row = self::buildBaseRow( + $row = $this->buildBaseRow( $runUuid, 'delete', $triggerType, @@ -89,13 +93,13 @@ class UserLifecycleAuditService ); $row['snapshot_enc'] = Crypto::encryptString($json); $row['snapshot_version'] = self::SNAPSHOT_VERSION; - return UserLifecycleAuditRepository::create($row); + return $this->userLifecycleAuditRepository->create($row); } catch (\Throwable $exception) { return false; } } - public static function logDeleteFailure( + public function logDeleteFailure( string $runUuid, string $triggerType, array $policy, @@ -104,7 +108,7 @@ class UserLifecycleAuditService string $reasonCode ): bool { try { - return UserLifecycleAuditRepository::create(self::buildBaseRow( + return $this->userLifecycleAuditRepository->create($this->buildBaseRow( $runUuid, 'delete', $triggerType, @@ -119,7 +123,7 @@ class UserLifecycleAuditService } } - public static function logRestore( + public function logRestore( string $runUuid, string $triggerType, array $policy, @@ -129,7 +133,7 @@ class UserLifecycleAuditService ?string $reasonCode = null ): bool { try { - return UserLifecycleAuditRepository::create(self::buildBaseRow( + return $this->userLifecycleAuditRepository->create($this->buildBaseRow( $runUuid, 'restore', $triggerType, @@ -144,31 +148,31 @@ class UserLifecycleAuditService } } - public static function markDeleteEventRestored(int $auditId, int $restoredByUserId, int $restoredUserId): bool + public function markDeleteEventRestored(int $auditId, int $restoredByUserId, int $restoredUserId): bool { try { - return UserLifecycleAuditRepository::markRestored($auditId, $restoredByUserId, $restoredUserId); + return $this->userLifecycleAuditRepository->markRestored($auditId, $restoredByUserId, $restoredUserId); } catch (\Throwable $exception) { return false; } } - public static function listPaged(array $filters): array + public function listPaged(array $filters): array { - return UserLifecycleAuditRepository::listPaged($filters); + return $this->userLifecycleAuditRepository->listPaged($filters); } - public static function find(int $id): ?array + public function find(int $id): ?array { - return UserLifecycleAuditRepository::find($id); + return $this->userLifecycleAuditRepository->find($id); } - public static function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array + public function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array { - return UserLifecycleAuditRepository::findDeleteEventForRestore($id, $forUpdate); + return $this->userLifecycleAuditRepository->findDeleteEventForRestore($id, $forUpdate); } - public static function decryptSnapshot(array $event): ?array + public function decryptSnapshot(array $event): ?array { $enc = trim((string) ($event['snapshot_enc'] ?? '')); if ($enc === '') { @@ -183,21 +187,21 @@ class UserLifecycleAuditService } } - public static function updateStatus(int $id, string $status, ?string $reasonCode = null): bool + public function updateStatus(int $id, string $status, ?string $reasonCode = null): bool { try { - return UserLifecycleAuditRepository::updateStatus($id, $status, $reasonCode); + return $this->userLifecycleAuditRepository->updateStatus($id, $status, $reasonCode); } catch (\Throwable $exception) { return false; } } - public static function purgeExpired(): int + public function purgeExpired(): int { - return UserLifecycleAuditRepository::purgeOlderThanDays(self::RETENTION_DAYS); + return $this->userLifecycleAuditRepository->purgeOlderThanDays(self::RETENTION_DAYS); } - private static function buildBaseRow( + private function buildBaseRow( string $runUuid, string $action, string $triggerType, @@ -226,14 +230,14 @@ class UserLifecycleAuditService 'policy_delete_days' => max(0, (int) ($policy['delete_days'] ?? 0)), 'actor_user_id' => ($actorUserId ?? 0) > 0 ? (int) $actorUserId : null, 'target_user_id' => ((int) ($targetUser['id'] ?? 0)) > 0 ? (int) $targetUser['id'] : null, - 'target_user_uuid' => self::stringOrNull($targetUser['uuid'] ?? null), - 'target_user_email' => self::stringOrNull($targetUser['email'] ?? null), + 'target_user_uuid' => $this->stringOrNull($targetUser['uuid'] ?? null), + 'target_user_email' => $this->stringOrNull($targetUser['email'] ?? null), 'snapshot_enc' => null, 'snapshot_version' => self::SNAPSHOT_VERSION, ]; } - private static function buildSnapshot(array $targetUser): array + private function buildSnapshot(array $targetUser): array { $snapshot = []; foreach (self::SNAPSHOT_FIELDS as $field) { @@ -242,10 +246,9 @@ class UserLifecycleAuditService return $snapshot; } - private static function stringOrNull(mixed $value): ?string + private function stringOrNull(mixed $value): ?string { $value = trim((string) $value); return $value !== '' ? $value : null; } } - diff --git a/lib/Service/Auth/ApiTokenService.php b/lib/Service/Auth/ApiTokenService.php index 11200d9..70dbca6 100644 --- a/lib/Service/Auth/ApiTokenService.php +++ b/lib/Service/Auth/ApiTokenService.php @@ -4,15 +4,21 @@ namespace MintyPHP\Service\Auth; use MintyPHP\DB; use MintyPHP\Repository\Auth\ApiTokenRepository; -use MintyPHP\Service\Settings\SettingService; -use MintyPHP\Repository\User\UserRepository; -use MintyPHP\Service\Tenant\TenantScopeService; +use MintyPHP\Repository\User\UserReadRepository; class ApiTokenService { private const MAX_TOKENS_PER_USER = 10; - private static function isTokenExpired(?string $expiresAt): bool + public function __construct( + private readonly UserReadRepository $userReadRepository, + private readonly ApiTokenRepository $apiTokenRepository, + private readonly AuthSettingsGateway $settingsGateway, + private readonly AuthScopeGateway $scopeGateway + ) { + } + + private function isTokenExpired(?string $expiresAt): bool { $expiresAt = trim((string) ($expiresAt ?? '')); if ($expiresAt === '') { @@ -32,7 +38,7 @@ class ApiTokenService * Returns ['ok' => true, 'token' => 'selector:plaintext', 'id' => int] on success. * The plain-text token is shown ONCE and never stored. */ - public static function create( + public function create( int $userId, string $name, ?int $tenantId = null, @@ -47,24 +53,24 @@ class ApiTokenService return ['ok' => false, 'error' => 'invalid_user']; } - $user = UserRepository::find($userId); + $user = $this->userReadRepository->find($userId); if (!$user || !((int) ($user['active'] ?? 0))) { return ['ok' => false, 'error' => 'user_inactive']; } - $activeCount = ApiTokenRepository::countActiveForUser($userId); + $activeCount = $this->apiTokenRepository->countActiveForUser($userId); if ($activeCount >= self::MAX_TOKENS_PER_USER) { return ['ok' => false, 'error' => 'max_tokens_reached']; } if ($tenantId !== null && $tenantId > 0) { - $userTenantIds = TenantScopeService::getUserTenantIds($userId); + $userTenantIds = $this->scopeGateway->getUserTenantIds($userId); if (!in_array($tenantId, $userTenantIds, true)) { return ['ok' => false, 'error' => 'tenant_not_assigned']; } } - $resolvedExpiresAt = self::resolveExpiresAt($expiresAt); + $resolvedExpiresAt = $this->resolveExpiresAt($expiresAt); if ($resolvedExpiresAt === null) { return ['ok' => false, 'error' => 'invalid_expires_at']; } @@ -73,7 +79,7 @@ class ApiTokenService $plainToken = bin2hex(random_bytes(32)); $tokenHash = hash('sha256', $plainToken); - $id = ApiTokenRepository::create( + $id = $this->apiTokenRepository->create( $userId, $name, $selector, @@ -87,7 +93,7 @@ class ApiTokenService return ['ok' => false, 'error' => 'create_failed']; } - $createdToken = ApiTokenRepository::find($id); + $createdToken = $this->apiTokenRepository->find($id); if (!$createdToken) { return ['ok' => false, 'error' => 'create_failed']; } @@ -103,22 +109,22 @@ class ApiTokenService ]; } - public static function rotateCurrentToken(int $userId, int $currentTokenId, ?string $expiresAt = null): array + public function rotateCurrentToken(int $userId, int $currentTokenId, ?string $expiresAt = null): array { if ($userId <= 0 || $currentTokenId <= 0) { return ['ok' => false, 'error' => 'current_token_not_found']; } - $currentToken = ApiTokenRepository::find($currentTokenId); + $currentToken = $this->apiTokenRepository->find($currentTokenId); if (!$currentToken || (int) ($currentToken['user_id'] ?? 0) !== $userId) { return ['ok' => false, 'error' => 'current_token_not_found']; } - if (!empty($currentToken['revoked_at']) || self::isTokenExpired($currentToken['expires_at'] ?? null)) { + if (!empty($currentToken['revoked_at']) || $this->isTokenExpired($currentToken['expires_at'] ?? null)) { return ['ok' => false, 'error' => 'current_token_invalid']; } - $activeCountExcludingCurrent = ApiTokenRepository::countActiveForUserExcludingId($userId, $currentTokenId); + $activeCountExcludingCurrent = $this->apiTokenRepository->countActiveForUserExcludingId($userId, $currentTokenId); if ($activeCountExcludingCurrent >= self::MAX_TOKENS_PER_USER) { return ['ok' => false, 'error' => 'max_tokens_reached']; } @@ -139,12 +145,12 @@ class ApiTokenService $db->begin_transaction(); $transactionStarted = true; - if (!ApiTokenRepository::revoke($currentTokenId)) { + if (!$this->apiTokenRepository->revoke($currentTokenId)) { $db->rollback(); return ['ok' => false, 'error' => 'rotate_failed']; } - $created = self::create($userId, $name, $tenantId, $expiresAt, $userId); + $created = $this->create($userId, $name, $tenantId, $expiresAt, $userId); if (!($created['ok'] ?? false)) { $db->rollback(); $error = (string) ($created['error'] ?? 'rotate_failed'); @@ -172,15 +178,15 @@ class ApiTokenService } } - private static function resolveExpiresAt(?string $expiresAt): ?string + private function resolveExpiresAt(?string $expiresAt): ?string { $nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); - $maxTtlDays = SettingService::getApiTokenMaxTtlDays(); + $maxTtlDays = $this->settingsGateway->getApiTokenMaxTtlDays(); $maxExpiryUtc = $nowUtc->modify('+' . $maxTtlDays . ' days'); $raw = trim((string) ($expiresAt ?? '')); if ($raw === '') { - $defaultDays = SettingService::getApiTokenDefaultTtlDays(); + $defaultDays = $this->settingsGateway->getApiTokenDefaultTtlDays(); $defaultExpiry = $nowUtc->modify('+' . $defaultDays . ' days'); if ($defaultExpiry > $maxExpiryUtc) { $defaultExpiry = $maxExpiryUtc; @@ -210,7 +216,7 @@ class ApiTokenService * * @return array{user: array, token_record: array, tenant_id: ?int}|null */ - public static function validate(string $bearerToken): ?array + public function validate(string $bearerToken): ?array { if ($bearerToken === '' || strpos($bearerToken, ':') === false) { return null; @@ -223,10 +229,10 @@ class ApiTokenService return null; } - $record = ApiTokenRepository::findBySelector($selector); + $record = $this->apiTokenRepository->findBySelector($selector); if (!$record) { // Perform a dummy hash to prevent timing-based selector enumeration. - hash('sha256', $plainToken); + $_ = hash('sha256', $plainToken); return null; } @@ -234,7 +240,7 @@ class ApiTokenService return null; } - if (self::isTokenExpired($record['expires_at'] ?? null)) { + if ($this->isTokenExpired($record['expires_at'] ?? null)) { return null; } @@ -248,13 +254,13 @@ class ApiTokenService return null; } - $user = UserRepository::find($userId); + $user = $this->userReadRepository->find($userId); if (!$user || !((int) ($user['active'] ?? 0))) { return null; } $ip = $_SERVER['REMOTE_ADDR'] ?? ''; - ApiTokenRepository::updateLastUsed((int) $record['id'], $ip); + $this->apiTokenRepository->updateLastUsed((int) $record['id'], $ip); return [ 'user' => $user, @@ -266,35 +272,35 @@ class ApiTokenService /** * Revoke a specific token by ID. */ - public static function revoke(int $tokenId): array + public function revoke(int $tokenId): array { - $token = ApiTokenRepository::find($tokenId); + $token = $this->apiTokenRepository->find($tokenId); if (!$token) { return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } - ApiTokenRepository::revoke($tokenId); + $this->apiTokenRepository->revoke($tokenId); return ['ok' => true]; } /** * Revoke all active tokens for a user. */ - public static function revokeAllForUser(int $userId, ?int $tenantId = null): array + public function revokeAllForUser(int $userId, ?int $tenantId = null): array { - $count = ApiTokenRepository::revokeAllForUser($userId, $tenantId); + $count = $this->apiTokenRepository->revokeAllForUser($userId, $tenantId); return ['ok' => true, 'count' => $count]; } - public static function revokeAllByAdmin(): int + public function revokeAllByAdmin(): int { - return ApiTokenRepository::revokeAllActiveByAdmin(); + return $this->apiTokenRepository->revokeAllActiveByAdmin(); } /** * List tokens for a user (for admin UI display -- never exposes hashes). */ - public static function listForUser(int $userId): array + public function listForUser(int $userId): array { - return ApiTokenRepository::listByUserId($userId); + return $this->apiTokenRepository->listByUserId($userId); } } diff --git a/lib/Service/Auth/AuthAvatarGateway.php b/lib/Service/Auth/AuthAvatarGateway.php new file mode 100644 index 0000000..4bbccac --- /dev/null +++ b/lib/Service/Auth/AuthAvatarGateway.php @@ -0,0 +1,22 @@ +userAvatarService->isValidUuid($uuid); + } + + public function saveBinary(string $uuid, string $binary, string $mime): void + { + $this->userAvatarService->saveBinary($uuid, $binary, $mime); + } +} diff --git a/lib/Service/Auth/AuthCryptoGateway.php b/lib/Service/Auth/AuthCryptoGateway.php new file mode 100644 index 0000000..6ace760 --- /dev/null +++ b/lib/Service/Auth/AuthCryptoGateway.php @@ -0,0 +1,23 @@ +userExternalIdentityRepository->findByProviderTidOid($provider, $tid, $oid); + } + + public function findByProviderIssuerSubject(string $provider, string $issuer, string $subject): ?array + { + return $this->userExternalIdentityRepository->findByProviderIssuerSubject($provider, $issuer, $subject); + } + + public function createLink(array $data): bool + { + return (bool) $this->userExternalIdentityRepository->createLink($data); + } +} diff --git a/lib/Service/Auth/AuthPermissionGateway.php b/lib/Service/Auth/AuthPermissionGateway.php new file mode 100644 index 0000000..033eb87 --- /dev/null +++ b/lib/Service/Auth/AuthPermissionGateway.php @@ -0,0 +1,22 @@ +permissionGateway->getUserPermissions($userId, $forceRefresh); + } + + public function userHas(int $userId, string $permissionKey): bool + { + return $this->permissionGateway->userHas($userId, $permissionKey); + } +} diff --git a/lib/Service/Auth/AuthSavedFilterGateway.php b/lib/Service/Auth/AuthSavedFilterGateway.php new file mode 100644 index 0000000..2c50134 --- /dev/null +++ b/lib/Service/Auth/AuthSavedFilterGateway.php @@ -0,0 +1,17 @@ +userSavedFilterService->listForAddressBook($userId); + } +} diff --git a/lib/Service/Auth/AuthScopeGateway.php b/lib/Service/Auth/AuthScopeGateway.php new file mode 100644 index 0000000..0a81a34 --- /dev/null +++ b/lib/Service/Auth/AuthScopeGateway.php @@ -0,0 +1,42 @@ +tenantScopeService()->hasGlobalAccess($userId); + } + + public function getUserTenantIds(int $userId): array + { + return $this->tenantScopeService()->getUserTenantIds($userId); + } + + public function canAccess(string $resourceType, int $resourceId, int $userId): bool + { + return $this->tenantScopeService()->canAccess($resourceType, $resourceId, $userId); + } + + public function resourceBelongsToTenant(string $resourceType, int $resourceId, int $tenantId): bool + { + return $this->tenantScopeService()->resourceBelongsToTenant($resourceType, $resourceId, $tenantId); + } + + private function tenantScopeService(): TenantScopeService + { + if ($this->tenantScopeService instanceof TenantScopeService) { + return $this->tenantScopeService; + } + + return (new TenantServicesFactory())->createTenantScopeService(); + } +} diff --git a/lib/Service/Auth/AuthService.php b/lib/Service/Auth/AuthService.php index 45144e1..673d71d 100644 --- a/lib/Service/Auth/AuthService.php +++ b/lib/Service/Auth/AuthService.php @@ -3,25 +3,35 @@ namespace MintyPHP\Service\Auth; use MintyPHP\Auth; -use MintyPHP\Session; +use MintyPHP\Repository\User\UserReadRepository; +use MintyPHP\Repository\User\UserWriteRepository; use MintyPHP\Router; +use MintyPHP\Service\User\UserAccountService; +use MintyPHP\Service\User\UserTenantContextService; +use MintyPHP\Session; use MintyPHP\Support\Flash; -use MintyPHP\Service\User\UserService; -use MintyPHP\Service\User\UserSavedFilterService; -use MintyPHP\Service\Access\PermissionService; -use MintyPHP\Service\Auth\RememberMeService; -use MintyPHP\Service\Auth\EmailVerificationService; -use MintyPHP\Service\Auth\TenantSsoService; -use MintyPHP\Repository\User\UserRepository; class AuthService { - public static function login(string $email, string $password): array + public function __construct( + private readonly UserReadRepository $userReadRepository, + private readonly UserWriteRepository $userWriteRepository, + private readonly UserAccountService $userAccountService, + private readonly UserTenantContextService $userTenantContextService, + private readonly RememberMeService $rememberMeService, + private readonly EmailVerificationService $emailVerificationService, + private readonly AuthPermissionGateway $permissionGateway, + private readonly AuthTenantSsoGateway $tenantSsoGateway, + private readonly AuthSavedFilterGateway $savedFilterGateway + ) { + } + + public function login(string $email, string $password): array { $email = trim($email); // Check if email is verified before attempting login - $existingUser = UserRepository::findByEmail($email); + $existingUser = $this->userReadRepository->findByEmail($email); if ($existingUser && empty($existingUser['email_verified_at'])) { return [ 'ok' => false, @@ -52,7 +62,7 @@ class AuthService } $userId = (int) ($_SESSION['user']['id'] ?? 0); - if ($userId > 0 && !self::isLocalPasswordLoginAllowedForUser($userId)) { + if ($userId > 0 && !$this->isLocalPasswordLoginAllowedForUser($userId)) { Auth::logout(); return [ 'ok' => false, @@ -61,8 +71,8 @@ class AuthService ]; } if ($userId > 0) { - PermissionService::getUserPermissions($userId, true); - self::loadTenantDataIntoSession($userId); + $this->permissionGateway->warmUserPermissions($userId, true); + $this->loadTenantDataIntoSession($userId); if (!empty($_SESSION['no_active_tenant'])) { Auth::logout(); return [ @@ -71,19 +81,19 @@ class AuthService 'flash_key' => 'login_no_active_tenant', ]; } - UserRepository::updateLastLogin($userId, 'local'); + $this->userWriteRepository->updateLastLogin($userId, 'local'); } return ['ok' => true]; } - private static function isLocalPasswordLoginAllowedForUser(int $userId): bool + private function isLocalPasswordLoginAllowedForUser(int $userId): bool { if ($userId <= 0) { return false; } - $availableTenants = UserService::getAvailableTenants($userId); + $availableTenants = $this->userTenantContextService->getAvailableTenants($userId); if (!$availableTenants) { return true; } @@ -93,7 +103,7 @@ class AuthService if ($tenantId <= 0) { continue; } - if (TenantSsoService::isLocalPasswordLoginAllowed($tenantId)) { + if ($this->tenantSsoGateway->isLocalPasswordLoginAllowed($tenantId)) { return true; } } @@ -101,13 +111,13 @@ class AuthService return false; } - public static function canLoginToTenant(int $userId, int $tenantId): bool + public function canLoginToTenant(int $userId, int $tenantId): bool { if ($userId <= 0 || $tenantId <= 0) { return false; } - foreach (UserService::getAvailableTenants($userId) as $tenant) { + foreach ($this->userTenantContextService->getAvailableTenants($userId) as $tenant) { if ((int) ($tenant['id'] ?? 0) === $tenantId) { return true; } @@ -116,13 +126,13 @@ class AuthService return false; } - public static function loginUserById(int $userId, ?int $preferredTenantId = null, string $loginProvider = 'local'): array + public function loginUserById(int $userId, ?int $preferredTenantId = null, string $loginProvider = 'local'): array { if ($userId <= 0) { return ['ok' => false, 'error' => 'user_not_found']; } - $user = UserRepository::find($userId); + $user = $this->userReadRepository->find($userId); if (!$user) { return ['ok' => false, 'error' => 'user_not_found']; } @@ -134,31 +144,31 @@ class AuthService $_SESSION['user'] = $user; if ($preferredTenantId !== null && $preferredTenantId > 0) { - $setTenant = UserService::setCurrentTenant($userId, $preferredTenantId); + $setTenant = $this->userTenantContextService->setCurrentTenant($userId, $preferredTenantId); if ($setTenant['ok'] ?? false) { $_SESSION['user']['current_tenant_id'] = $preferredTenantId; } } - PermissionService::getUserPermissions($userId, true); - self::loadTenantDataIntoSession($userId); + $this->permissionGateway->warmUserPermissions($userId, true); + $this->loadTenantDataIntoSession($userId); if (!empty($_SESSION['no_active_tenant'])) { Auth::logout(); return ['ok' => false, 'error' => 'login_no_active_tenant']; } - UserRepository::updateLastLogin($userId, $loginProvider); + $this->userWriteRepository->updateLastLogin($userId, $loginProvider); return ['ok' => true, 'user' => $user]; } - public static function loginAndRedirect( + public function loginAndRedirect( string $email, string $password, string $successTarget = 'admin', string $failTarget = 'login', bool $remember = false ): void { - $result = self::login($email, $password); + $result = $this->login($email, $password); if (!($result['ok'] ?? false)) { // Check if user needs to verify email @@ -177,45 +187,45 @@ class AuthService if ($remember) { $userId = (int) ($_SESSION['user']['id'] ?? 0); if ($userId > 0) { - RememberMeService::rememberUser($userId); + $this->rememberMeService->rememberUser($userId); } } Router::redirect($successTarget); } - public static function register(array $data): array + public function register(array $data): array { $email = trim((string) ($data['email'] ?? '')); - $result = UserService::register($data); + $result = $this->userAccountService->register($data); if (!($result['ok'] ?? false)) { return $result; } // Get the created user to send verification email - $createdUser = UserRepository::findByEmail($email); + $createdUser = $this->userReadRepository->findByEmail($email); if ($createdUser && isset($createdUser['id'])) { $userId = (int) $createdUser['id']; - EmailVerificationService::sendVerification($userId); + $this->emailVerificationService->sendVerification($userId); } // Don't login - user needs to verify email first return ['ok' => true, 'email' => $email, 'needs_verification' => true]; } - public static function logout(): void + public function logout(): void { - RememberMeService::forgetCurrentUser(); + $this->rememberMeService->forgetCurrentUser(); Auth::logout(); } - public static function logoutAndRedirect(string $target = 'login'): void + public function logoutAndRedirect(string $target = 'login'): void { - RememberMeService::forgetCurrentUser(); + $this->rememberMeService->forgetCurrentUser(); Auth::logout(); Router::redirect($target); } - public static function refreshSessionAuthState(int $userId): array + public function refreshSessionAuthState(int $userId): array { if ($userId <= 0) { return [ @@ -225,7 +235,7 @@ class AuthService ]; } - $snapshot = UserRepository::findAuthzSnapshot($userId); + $snapshot = $this->userReadRepository->findAuthzSnapshot($userId); if (!$snapshot) { return [ 'ok' => false, @@ -247,7 +257,7 @@ class AuthService $refreshed = false; if ($sessionVersion !== $dbVersion) { - $user = UserRepository::find($userId); + $user = $this->userReadRepository->find($userId); if (!$user || (int) ($user['active'] ?? 0) !== 1) { return [ 'ok' => false, @@ -257,8 +267,8 @@ class AuthService } $_SESSION['user'] = $user; - PermissionService::getUserPermissions($userId, true); - self::loadTenantDataIntoSession($userId); + $this->permissionGateway->warmUserPermissions($userId, true); + $this->loadTenantDataIntoSession($userId); $refreshed = true; } @@ -278,17 +288,17 @@ class AuthService ]; } - public static function loadTenantDataIntoSession(int $userId): void + public function loadTenantDataIntoSession(int $userId): void { if ($userId <= 0) { return; } // Load available tenants first - $availableTenants = UserService::getAvailableTenants($userId); + $availableTenants = $this->userTenantContextService->getAvailableTenants($userId); $_SESSION['available_tenants'] = $availableTenants; - $_SESSION['available_departments_by_tenant'] = UserService::getAvailableDepartmentsByTenant($userId); - $_SESSION['address_book_saved_filters'] = UserSavedFilterService::listForAddressBook($userId); + $_SESSION['available_departments_by_tenant'] = $this->userTenantContextService->getAvailableDepartmentsByTenant($userId); + $_SESSION['address_book_saved_filters'] = $this->savedFilterGateway->listAddressBookFilters($userId); if (!$availableTenants) { $_SESSION['no_active_tenant'] = true; @@ -298,7 +308,7 @@ class AuthService $_SESSION['no_active_tenant'] = false; // Load current tenant (with fallback to first available) - $currentTenant = UserService::getCurrentTenant($userId); + $currentTenant = $this->userTenantContextService->getCurrentTenant($userId); if (!$currentTenant) { // Fallback: use first available tenant $currentTenant = $availableTenants[0]; diff --git a/lib/Service/Auth/AuthServicesFactory.php b/lib/Service/Auth/AuthServicesFactory.php new file mode 100644 index 0000000..9048853 --- /dev/null +++ b/lib/Service/Auth/AuthServicesFactory.php @@ -0,0 +1,271 @@ +apiTokenService ??= new ApiTokenService( + $this->userServicesFactory()->createUserReadRepository(), + $this->createApiTokenRepository(), + $this->createAuthSettingsGateway(), + $this->createAuthScopeGateway() + ); + } + + public function createPasswordResetService(): PasswordResetService + { + return $this->passwordResetService ??= new PasswordResetService( + $this->userServicesFactory()->createUserReadRepository(), + $this->createPasswordResetRepository(), + $this->userServicesFactory()->createUserPasswordService(), + $this->createRememberMeService(), + $this->mailServicesFactory()->createMailService() + ); + } + + public function createEmailVerificationService(): EmailVerificationService + { + return $this->emailVerificationService ??= new EmailVerificationService( + $this->userServicesFactory()->createUserReadRepository(), + $this->userServicesFactory()->createUserWriteRepository(), + $this->createEmailVerificationRepository(), + $this->mailServicesFactory()->createMailService() + ); + } + + public function createRememberMeService(): RememberMeService + { + return $this->rememberMeService ??= new RememberMeService( + $this->userServicesFactory()->createUserReadRepository(), + $this->userServicesFactory()->createUserWriteRepository(), + $this->createRememberTokenRepository(), + $this->userServicesFactory()->createUserTenantContextService(), + $this->createAuthPermissionGateway(), + $this->createAuthSavedFilterGateway() + ); + } + + public function createAuthService(): AuthService + { + return $this->authService ??= new AuthService( + $this->userServicesFactory()->createUserReadRepository(), + $this->userServicesFactory()->createUserWriteRepository(), + $this->userServicesFactory()->createUserAccountService(), + $this->userServicesFactory()->createUserTenantContextService(), + $this->createRememberMeService(), + $this->createEmailVerificationService(), + $this->createAuthPermissionGateway(), + $this->createAuthTenantSsoGateway(), + $this->createAuthSavedFilterGateway() + ); + } + + public function createSsoUserLinkService(): SsoUserLinkService + { + return $this->ssoUserLinkService ??= new SsoUserLinkService( + $this->userServicesFactory()->createUserReadRepository(), + $this->userServicesFactory()->createUserWriteRepository(), + $this->userServicesFactory()->createUserAssignmentService(), + $this->userServicesFactory()->createUserTenantRepository(), + $this->createAuthSettingsGateway(), + $this->createAuthTenantSsoGateway(), + $this->createAuthAvatarGateway(), + $this->createAuthExternalIdentityGateway() + ); + } + + public function createTenantSsoService(): TenantSsoService + { + return $this->tenantSsoService ??= new TenantSsoService( + $this->createAuthTenantGateway(), + $this->createTenantMicrosoftAuthRepository(), + $this->createAuthSettingsGateway(), + $this->createAuthCryptoGateway() + ); + } + + public function createMicrosoftOidcStateStore(): MicrosoftOidcStateStoreService + { + return $this->microsoftOidcStateStoreService ??= new MicrosoftOidcStateStoreService(); + } + + public function createMicrosoftOidcService(): MicrosoftOidcService + { + return $this->microsoftOidcService ??= new MicrosoftOidcService( + $this->createTenantSsoService(), + $this->createAuthTenantGateway(), + $this->createOidcHttpGateway(), + $this->createMicrosoftOidcStateStore() + ); + } + + private function userServicesFactory(): UserServicesFactory + { + return $this->userServicesFactory ??= new UserServicesFactory(); + } + + private function createAuthSettingsGateway(): AuthSettingsGateway + { + return $this->authSettingsGateway ??= new AuthSettingsGateway($this->createSettingGateway()); + } + + public function createAuthScopeGateway(): AuthScopeGateway + { + return $this->authScopeGateway ??= new AuthScopeGateway($this->tenantServicesFactory()->createTenantScopeService()); + } + + private function createAuthPermissionGateway(): AuthPermissionGateway + { + return $this->authPermissionGateway ??= new AuthPermissionGateway($this->createPermissionGateway()); + } + + private function createPermissionGateway(): PermissionGateway + { + return $this->permissionGateway ??= $this->accessServicesFactory()->createPermissionGateway(); + } + + private function accessServicesFactory(): AccessServicesFactory + { + return $this->accessServicesFactory ??= new AccessServicesFactory(); + } + + private function createSettingGateway(): SettingGateway + { + return $this->settingGateway ??= $this->settingServicesFactory()->createSettingGateway(); + } + + private function settingServicesFactory(): SettingServicesFactory + { + return $this->settingServicesFactory ??= new SettingServicesFactory(); + } + + private function mailServicesFactory(): MailServicesFactory + { + return $this->mailServicesFactory ??= new MailServicesFactory(); + } + + private function tenantServicesFactory(): TenantServicesFactory + { + return $this->tenantServicesFactory ??= new TenantServicesFactory(); + } + + private function createAuthTenantGateway(): AuthTenantGateway + { + return $this->authTenantGateway ??= new AuthTenantGateway(); + } + + private function createAuthCryptoGateway(): AuthCryptoGateway + { + return $this->authCryptoGateway ??= new AuthCryptoGateway(); + } + + private function createAuthTenantSsoGateway(): AuthTenantSsoGateway + { + return $this->authTenantSsoGateway ??= new AuthTenantSsoGateway($this->createTenantSsoService()); + } + + private function createAuthSavedFilterGateway(): AuthSavedFilterGateway + { + return $this->authSavedFilterGateway ??= new AuthSavedFilterGateway( + $this->userServicesFactory()->createUserSavedFilterService() + ); + } + + private function createAuthAvatarGateway(): AuthAvatarGateway + { + return $this->authAvatarGateway ??= new AuthAvatarGateway( + $this->userServicesFactory()->createUserAvatarService() + ); + } + + private function createAuthExternalIdentityGateway(): AuthExternalIdentityGateway + { + return $this->authExternalIdentityGateway ??= new AuthExternalIdentityGateway( + $this->createUserExternalIdentityRepository() + ); + } + + private function createTenantMicrosoftAuthRepository(): TenantMicrosoftAuthRepository + { + return $this->tenantMicrosoftAuthRepository ??= new TenantMicrosoftAuthRepository(); + } + + private function createApiTokenRepository(): ApiTokenRepository + { + return $this->apiTokenRepository ??= new ApiTokenRepository(); + } + + private function createRememberTokenRepository(): RememberTokenRepository + { + return $this->rememberTokenRepository ??= new RememberTokenRepository(); + } + + private function createPasswordResetRepository(): PasswordResetRepository + { + return $this->passwordResetRepository ??= new PasswordResetRepository(); + } + + private function createEmailVerificationRepository(): EmailVerificationRepository + { + return $this->emailVerificationRepository ??= new EmailVerificationRepository(); + } + + private function createUserExternalIdentityRepository(): UserExternalIdentityRepository + { + return $this->userExternalIdentityRepository ??= new UserExternalIdentityRepository(); + } + + private function createOidcHttpGateway(): OidcHttpGateway + { + return $this->oidcHttpGateway ??= new OidcHttpGateway(); + } +} diff --git a/lib/Service/Auth/AuthSettingsGateway.php b/lib/Service/Auth/AuthSettingsGateway.php new file mode 100644 index 0000000..d69d97d --- /dev/null +++ b/lib/Service/Auth/AuthSettingsGateway.php @@ -0,0 +1,52 @@ +settingGateway->getMicrosoftSharedClientId(); + } + + public function getMicrosoftSharedClientSecret(): string + { + return (string) $this->settingGateway->getMicrosoftSharedClientSecret(); + } + + public function getMicrosoftAuthority(): string + { + return (string) $this->settingGateway->getMicrosoftAuthority(); + } + + public function getApiTokenMaxTtlDays(): int + { + return $this->settingGateway->getApiTokenMaxTtlDays(); + } + + public function getApiTokenDefaultTtlDays(): int + { + return $this->settingGateway->getApiTokenDefaultTtlDays(); + } + + public function getAppTheme(): string + { + return (string) $this->settingGateway->getAppTheme(); + } + + public function getDefaultRoleId(): int + { + return (int) $this->settingGateway->getDefaultRoleId(); + } + + public function getDefaultDepartmentId(): int + { + return (int) $this->settingGateway->getDefaultDepartmentId(); + } +} diff --git a/lib/Service/Auth/AuthTenantGateway.php b/lib/Service/Auth/AuthTenantGateway.php new file mode 100644 index 0000000..1878d17 --- /dev/null +++ b/lib/Service/Auth/AuthTenantGateway.php @@ -0,0 +1,23 @@ +find($tenantId); + } + + public function findByUuid(string $uuid): ?array + { + return (new TenantRepository())->findByUuid($uuid); + } + + public function list(): array + { + return (new TenantRepository())->list(); + } +} diff --git a/lib/Service/Auth/AuthTenantSsoGateway.php b/lib/Service/Auth/AuthTenantSsoGateway.php new file mode 100644 index 0000000..890f974 --- /dev/null +++ b/lib/Service/Auth/AuthTenantSsoGateway.php @@ -0,0 +1,30 @@ +tenantSsoService->isLocalPasswordLoginAllowed($tenantId); + } + + public function normalizeProfileSyncFields($fields): array + { + return $this->tenantSsoService->normalizeProfileSyncFields($fields); + } + + public function defaultProfileSyncFields(): array + { + return $this->tenantSsoService->defaultProfileSyncFields(); + } + + public function microsoftProviderKey(): string + { + return $this->tenantSsoService->microsoftProviderKey(); + } +} diff --git a/lib/Service/Auth/EmailVerificationService.php b/lib/Service/Auth/EmailVerificationService.php index 46d0b59..447b9de 100644 --- a/lib/Service/Auth/EmailVerificationService.php +++ b/lib/Service/Auth/EmailVerificationService.php @@ -2,10 +2,11 @@ namespace MintyPHP\Service\Auth; -use MintyPHP\Repository\Auth\EmailVerificationRepository; -use MintyPHP\Repository\User\UserRepository; -use MintyPHP\I18n; use MintyPHP\Http\Request; +use MintyPHP\I18n; +use MintyPHP\Repository\Auth\EmailVerificationRepository; +use MintyPHP\Repository\User\UserReadRepository; +use MintyPHP\Repository\User\UserWriteRepository; use MintyPHP\Service\Mail\MailService; class EmailVerificationService @@ -14,9 +15,17 @@ class EmailVerificationService private const EXPIRY_MINUTES = 30; private const MAX_ATTEMPTS = 5; - public static function sendVerification(int $userId, ?string $locale = null): array + public function __construct( + private readonly UserReadRepository $userReadRepository, + private readonly UserWriteRepository $userWriteRepository, + private readonly EmailVerificationRepository $emailVerificationRepository, + private readonly MailService $mailService + ) { + } + + public function sendVerification(int $userId, ?string $locale = null): array { - $user = UserRepository::find($userId); + $user = $this->userReadRepository->find($userId); if (!$user || !isset($user['id'])) { return ['ok' => false, 'error' => 'user_not_found']; } @@ -26,13 +35,13 @@ class EmailVerificationService return ['ok' => false, 'error' => 'email_required']; } - EmailVerificationRepository::invalidateForUser($userId); + $this->emailVerificationRepository->invalidateForUser($userId); - $code = self::generateCode(); + $code = $this->generateCode(); $codeHash = password_hash($code, PASSWORD_DEFAULT); $expiresAt = gmdate('Y-m-d H:i:s', time() + (self::EXPIRY_MINUTES * 60)); - $verificationId = EmailVerificationRepository::create($userId, $codeHash, $expiresAt); + $verificationId = $this->emailVerificationRepository->create($userId, $codeHash, $expiresAt); if (!$verificationId) { return ['ok' => false, 'error' => 'create_failed']; } @@ -62,12 +71,12 @@ class EmailVerificationService 'verify_url' => $verifyUrl, 'greeting' => $greeting, ]; - MailService::sendTemplate('email_verification', $vars, $email, $subject, $locale); + $this->mailService->sendTemplate('email_verification', $vars, $email, $subject, $locale); return ['ok' => true]; } - public static function verifyCode(string $email, string $code): array + public function verifyCode(string $email, string $code): array { $email = trim($email); $code = trim($code); @@ -75,7 +84,7 @@ class EmailVerificationService return ['ok' => false, 'error' => 'invalid']; } - $user = UserRepository::findByEmail($email); + $user = $this->userReadRepository->findByEmail($email); if (!$user || !isset($user['id'])) { return ['ok' => false, 'error' => 'invalid']; } @@ -87,7 +96,7 @@ class EmailVerificationService return ['ok' => false, 'error' => 'already_verified']; } - $verification = EmailVerificationRepository::findActiveByUserId($userId); + $verification = $this->emailVerificationRepository->findActiveByUserId($userId); if (!$verification || !isset($verification['id'])) { return ['ok' => false, 'error' => 'invalid']; } @@ -99,27 +108,27 @@ class EmailVerificationService $hash = (string) ($verification['code_hash'] ?? ''); if ($hash === '' || !password_verify($code, $hash)) { - EmailVerificationRepository::incrementAttempts((int) $verification['id']); + $this->emailVerificationRepository->incrementAttempts((int) $verification['id']); return ['ok' => false, 'error' => 'invalid']; } // Mark verification as used - EmailVerificationRepository::markUsed((int) $verification['id']); + $this->emailVerificationRepository->markUsed((int) $verification['id']); // Mark user email as verified - UserRepository::setEmailVerified($userId); + $this->userWriteRepository->setEmailVerified($userId); return ['ok' => true, 'user_id' => $userId]; } - public static function resendVerification(string $email, ?string $locale = null): array + public function resendVerification(string $email, ?string $locale = null): array { $email = trim($email); if ($email === '') { return ['ok' => false, 'error' => 'email_required']; } - $user = UserRepository::findByEmail($email); + $user = $this->userReadRepository->findByEmail($email); if (!$user || !isset($user['id'])) { // Don't reveal if user exists return ['ok' => true]; @@ -130,15 +139,15 @@ class EmailVerificationService return ['ok' => false, 'error' => 'already_verified']; } - return self::sendVerification((int) $user['id'], $locale); + return $this->sendVerification((int) $user['id'], $locale); } - public static function getExpiryMinutes(): int + public function getExpiryMinutes(): int { return self::EXPIRY_MINUTES; } - private static function generateCode(): string + private function generateCode(): string { $max = (10 ** self::CODE_LENGTH) - 1; $code = (string) random_int(0, $max); diff --git a/lib/Service/Auth/MicrosoftOidcService.php b/lib/Service/Auth/MicrosoftOidcService.php index c1aa583..b47ae48 100644 --- a/lib/Service/Auth/MicrosoftOidcService.php +++ b/lib/Service/Auth/MicrosoftOidcService.php @@ -2,25 +2,29 @@ namespace MintyPHP\Service\Auth; -use MintyPHP\Curl; use MintyPHP\Http\Request; use MintyPHP\I18n; -use MintyPHP\Repository\Tenant\TenantRepository; class MicrosoftOidcService { - private const SESSION_KEY = 'microsoft_oidc_states'; - private const STATE_TTL = 600; private const CLOCK_SKEW = 120; - public static function startAuthorization(array $tenant, array $providerConfig): array + public function __construct( + private readonly TenantSsoService $tenantSsoService, + private readonly AuthTenantGateway $tenantGateway, + private readonly OidcHttpGateway $oidcHttpGateway, + private readonly MicrosoftOidcStateStoreService $stateStore + ) { + } + + public function startAuthorization(array $tenant, array $providerConfig): array { $tenantId = (int) ($tenant['id'] ?? 0); if ($tenantId <= 0) { return ['ok' => false, 'error' => 'tenant_not_found']; } - $oidc = self::fetchOpenIdConfiguration((string) ($providerConfig['authority'] ?? '')); + $oidc = $this->fetchOpenIdConfiguration((string) ($providerConfig['authority'] ?? '')); if (!($oidc['ok'] ?? false)) { return ['ok' => false, 'error' => 'oidc_discovery_failed']; } @@ -33,22 +37,21 @@ class MicrosoftOidcService $locale = I18n::$locale ?? I18n::$defaultLocale ?? 'de'; $redirectUri = appUrl(Request::withLocale('auth/microsoft/callback', $locale)); - self::pruneStates(); - $_SESSION[self::SESSION_KEY][$state] = [ + $this->stateStore->prune(); + $this->stateStore->store($state, [ 'tenant_id' => $tenantId, 'nonce' => $nonce, 'code_verifier' => $codeVerifier, 'locale' => $locale, 'redirect_uri' => $redirectUri, - 'created_at' => time(), - ]; + ]); $params = [ 'client_id' => (string) ($providerConfig['client_id'] ?? ''), 'response_type' => 'code', 'redirect_uri' => $redirectUri, 'response_mode' => 'query', - 'scope' => self::buildAuthorizationScope($providerConfig), + 'scope' => $this->buildAuthorizationScope($providerConfig), 'state' => $state, 'nonce' => $nonce, 'code_challenge' => $codeChallenge, @@ -66,7 +69,7 @@ class MicrosoftOidcService ]; } - public static function handleCallback(string $state, string $code): array + public function handleCallback(string $state, string $code): array { $state = trim($state); $code = trim($code); @@ -74,31 +77,25 @@ class MicrosoftOidcService return ['ok' => false, 'error' => 'callback_invalid']; } - $states = $_SESSION[self::SESSION_KEY] ?? []; - $entry = is_array($states[$state] ?? null) ? $states[$state] : null; - if (!$entry) { - return ['ok' => false, 'error' => 'state_invalid']; - } - unset($_SESSION[self::SESSION_KEY][$state]); - - $createdAt = (int) ($entry['created_at'] ?? 0); - if ($createdAt <= 0 || (time() - $createdAt) > self::STATE_TTL) { - return ['ok' => false, 'error' => 'state_expired']; + $consumeResult = $this->stateStore->consume($state); + if (!$consumeResult['ok']) { + return ['ok' => false, 'error' => (string) ($consumeResult['error'] ?? 'state_invalid')]; } + $entry = is_array($consumeResult['entry'] ?? null) ? $consumeResult['entry'] : []; $tenantId = (int) ($entry['tenant_id'] ?? 0); - $tenant = TenantRepository::find($tenantId); + $tenant = $this->tenantGateway->findById($tenantId); if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') { return ['ok' => false, 'error' => 'tenant_not_found']; } - $configResult = TenantSsoService::getEffectiveMicrosoftProviderConfig($tenant); + $configResult = $this->tenantSsoService->getEffectiveMicrosoftProviderConfig($tenant); if (!($configResult['ok'] ?? false)) { return ['ok' => false, 'error' => (string) ($configResult['error'] ?? 'sso_config_invalid')]; } $providerConfig = $configResult['config'] ?? []; - $oidc = self::fetchOpenIdConfiguration((string) ($providerConfig['authority'] ?? '')); + $oidc = $this->fetchOpenIdConfiguration((string) ($providerConfig['authority'] ?? '')); if (!($oidc['ok'] ?? false)) { return ['ok' => false, 'error' => 'oidc_discovery_failed']; } @@ -109,7 +106,7 @@ class MicrosoftOidcService return ['ok' => false, 'error' => 'token_endpoint_missing']; } - $tokenResponse = Curl::call('POST', $tokenEndpoint, [ + $tokenResponse = $this->oidcHttpGateway->call('POST', $tokenEndpoint, [ 'grant_type' => 'authorization_code', 'client_id' => (string) ($providerConfig['client_id'] ?? ''), 'client_secret' => (string) ($providerConfig['client_secret'] ?? ''), @@ -134,7 +131,7 @@ class MicrosoftOidcService } $accessToken = trim((string) ($tokenData['access_token'] ?? '')); - $validated = self::validateIdToken( + $validated = $this->validateIdToken( $idToken, $providerConfig, $metadata, @@ -152,25 +149,25 @@ class MicrosoftOidcService $allowedDomainsCsv = (string) ($providerConfig['allowed_domains'] ?? ''); $allowedDomains = array_values(array_filter(array_map('trim', explode(',', $allowedDomainsCsv)))); - if (!TenantSsoService::isEmailDomainAllowed($email, $allowedDomains)) { + if (!$this->tenantSsoService->isEmailDomainAllowed($email, $allowedDomains)) { return ['ok' => false, 'error' => 'email_domain_not_allowed']; } $syncEnabled = !empty($providerConfig['sync_profile_on_login']); - $syncFields = TenantSsoService::normalizeProfileSyncFields($providerConfig['sync_profile_fields'] ?? []); + $syncFields = $this->tenantSsoService->normalizeProfileSyncFields($providerConfig['sync_profile_fields'] ?? []); if ($syncEnabled && !$syncFields) { - $syncFields = TenantSsoService::defaultProfileSyncFields(); + $syncFields = $this->tenantSsoService->defaultProfileSyncFields(); } $graphProfile = []; - if ($syncEnabled && TenantSsoService::profileSyncFieldsRequireGraph($syncFields) && $accessToken !== '') { - $graph = self::fetchMicrosoftGraphProfile($accessToken); + if ($syncEnabled && $this->tenantSsoService->profileSyncFieldsRequireGraph($syncFields) && $accessToken !== '') { + $graph = $this->fetchMicrosoftGraphProfile($accessToken); if (!empty($graph['ok'])) { $graphProfile = is_array($graph['profile'] ?? null) ? $graph['profile'] : []; } } $graphAvatar = []; if ($syncEnabled && in_array('avatar', $syncFields, true) && $accessToken !== '') { - $avatar = self::fetchMicrosoftGraphAvatar($accessToken); + $avatar = $this->fetchMicrosoftGraphAvatar($accessToken); if (!empty($avatar['ok'])) { $graphAvatar = is_array($avatar['avatar'] ?? null) ? $avatar['avatar'] : []; } @@ -208,26 +205,26 @@ class MicrosoftOidcService ]; } - private static function buildAuthorizationScope(array $providerConfig): string + private function buildAuthorizationScope(array $providerConfig): string { $scopes = ['openid', 'profile', 'email']; $syncEnabled = !empty($providerConfig['sync_profile_on_login']); - $syncFields = TenantSsoService::normalizeProfileSyncFields($providerConfig['sync_profile_fields'] ?? []); - if ($syncEnabled && TenantSsoService::profileSyncFieldsRequireGraph($syncFields)) { + $syncFields = $this->tenantSsoService->normalizeProfileSyncFields($providerConfig['sync_profile_fields'] ?? []); + if ($syncEnabled && $this->tenantSsoService->profileSyncFieldsRequireGraph($syncFields)) { $scopes[] = 'User.Read'; } $scopes = array_values(array_unique($scopes)); return implode(' ', $scopes); } - private static function fetchOpenIdConfiguration(string $authority): array + private function fetchOpenIdConfiguration(string $authority): array { $authority = trim($authority); if ($authority === '') { return ['ok' => false]; } $url = rtrim($authority, '/') . '/.well-known/openid-configuration'; - $response = Curl::call('GET', $url, '', ['Accept' => 'application/json']); + $response = $this->oidcHttpGateway->call('GET', $url, '', ['Accept' => 'application/json']); if ((int) ($response['status'] ?? 0) < 200 || (int) ($response['status'] ?? 0) >= 300) { return ['ok' => false]; } @@ -238,7 +235,7 @@ class MicrosoftOidcService return ['ok' => true, 'config' => $config]; } - private static function validateIdToken( + private function validateIdToken( string $idToken, array $providerConfig, array $metadata, @@ -266,7 +263,7 @@ class MicrosoftOidcService return ['ok' => false, 'error' => 'jwks_uri_missing']; } - $jwksResponse = Curl::call('GET', $jwksUri, '', ['Accept' => 'application/json']); + $jwksResponse = $this->oidcHttpGateway->call('GET', $jwksUri, '', ['Accept' => 'application/json']); if ((int) ($jwksResponse['status'] ?? 0) < 200 || (int) ($jwksResponse['status'] ?? 0) >= 300) { return ['ok' => false, 'error' => 'jwks_fetch_failed']; } @@ -367,10 +364,10 @@ class MicrosoftOidcService return ''; } - private static function fetchMicrosoftGraphProfile(string $accessToken): array + private function fetchMicrosoftGraphProfile(string $accessToken): array { $url = 'https://graph.microsoft.com/v1.0/me?$select=givenName,surname,mobilePhone,businessPhones'; - $response = Curl::call('GET', $url, '', [ + $response = $this->oidcHttpGateway->call('GET', $url, '', [ 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $accessToken, ]); @@ -400,10 +397,10 @@ class MicrosoftOidcService ]; } - private static function fetchMicrosoftGraphAvatar(string $accessToken): array + private function fetchMicrosoftGraphAvatar(string $accessToken): array { $url = 'https://graph.microsoft.com/v1.0/me/photo/$value'; - $response = Curl::call('GET', $url, '', [ + $response = $this->oidcHttpGateway->call('GET', $url, '', [ 'Accept' => 'image/*', 'Authorization' => 'Bearer ' . $accessToken, ]); @@ -443,23 +440,6 @@ class MicrosoftOidcService return ''; } - private static function pruneStates(): void - { - $states = $_SESSION[self::SESSION_KEY] ?? []; - if (!is_array($states)) { - $_SESSION[self::SESSION_KEY] = []; - return; - } - $now = time(); - foreach ($states as $state => $payload) { - $createdAt = (int) (($payload['created_at'] ?? 0)); - if ($createdAt <= 0 || ($now - $createdAt) > self::STATE_TTL) { - unset($states[$state]); - } - } - $_SESSION[self::SESSION_KEY] = $states; - } - private static function randomString(int $bytes): string { return self::base64UrlEncode(random_bytes($bytes)); diff --git a/lib/Service/Auth/MicrosoftOidcStateStoreService.php b/lib/Service/Auth/MicrosoftOidcStateStoreService.php new file mode 100644 index 0000000..c0212c8 --- /dev/null +++ b/lib/Service/Auth/MicrosoftOidcStateStoreService.php @@ -0,0 +1,94 @@ +states(); + $states = $this->pruneExpiredStates($states); + + $payload['created_at'] = (int) ($payload['created_at'] ?? time()); + $states[$state] = $payload; + + if (count($states) > $this->maxEntries) { + uasort($states, static function (array $a, array $b): int { + return ((int) ($a['created_at'] ?? 0)) <=> ((int) ($b['created_at'] ?? 0)); + }); + while (count($states) > $this->maxEntries) { + array_shift($states); + } + } + + $_SESSION[self::SESSION_KEY] = $states; + } + + /** + * @return array{ok:bool,error?:string,entry?:array} + */ + public function consume(string $state): array + { + $state = trim($state); + if ($state === '') { + return ['ok' => false, 'error' => 'state_invalid']; + } + + $states = $this->states(); + $entry = is_array($states[$state] ?? null) ? $states[$state] : null; + if ($entry === null) { + return ['ok' => false, 'error' => 'state_invalid']; + } + + unset($states[$state]); + $_SESSION[self::SESSION_KEY] = $states; + + $createdAt = (int) ($entry['created_at'] ?? 0); + if ($createdAt <= 0 || (time() - $createdAt) > $this->ttlSeconds) { + return ['ok' => false, 'error' => 'state_expired']; + } + + return ['ok' => true, 'entry' => $entry]; + } + + public function prune(): void + { + $_SESSION[self::SESSION_KEY] = $this->pruneExpiredStates($this->states()); + } + + private function states(): array + { + $states = $_SESSION[self::SESSION_KEY] ?? []; + return is_array($states) ? $states : []; + } + + private function pruneExpiredStates(array $states): array + { + $now = time(); + foreach ($states as $key => $payload) { + if (!is_array($payload)) { + unset($states[$key]); + continue; + } + $createdAt = (int) ($payload['created_at'] ?? 0); + if ($createdAt <= 0 || ($now - $createdAt) > $this->ttlSeconds) { + unset($states[$key]); + } + } + + return $states; + } +} diff --git a/lib/Service/Auth/OidcHttpGateway.php b/lib/Service/Auth/OidcHttpGateway.php new file mode 100644 index 0000000..7486ca9 --- /dev/null +++ b/lib/Service/Auth/OidcHttpGateway.php @@ -0,0 +1,16 @@ + $headers + */ + public function call(string $method, string $url, mixed $data = '', array $headers = []): array + { + return Curl::call($method, $url, $data, $headers); + } +} diff --git a/lib/Service/Auth/PasswordResetService.php b/lib/Service/Auth/PasswordResetService.php index 7f28bae..c115fa9 100644 --- a/lib/Service/Auth/PasswordResetService.php +++ b/lib/Service/Auth/PasswordResetService.php @@ -2,13 +2,12 @@ namespace MintyPHP\Service\Auth; -use MintyPHP\Repository\Auth\PasswordResetRepository; -use MintyPHP\Repository\User\UserRepository; -use MintyPHP\I18n; use MintyPHP\Http\Request; -use MintyPHP\Service\Auth\RememberMeService; +use MintyPHP\I18n; +use MintyPHP\Repository\Auth\PasswordResetRepository; +use MintyPHP\Repository\User\UserReadRepository; use MintyPHP\Service\Mail\MailService; -use MintyPHP\Service\User\UserService; +use MintyPHP\Service\User\UserPasswordService; class PasswordResetService { @@ -16,26 +15,35 @@ class PasswordResetService private const EXPIRY_MINUTES = 15; private const MAX_ATTEMPTS = 5; - public static function requestReset(string $email, ?string $locale = null): array + public function __construct( + private readonly UserReadRepository $userReadRepository, + private readonly PasswordResetRepository $passwordResetRepository, + private readonly UserPasswordService $userPasswordService, + private readonly RememberMeService $rememberMeService, + private readonly MailService $mailService + ) { + } + + public function requestReset(string $email, ?string $locale = null): array { $email = trim($email); if ($email === '') { return ['ok' => false, 'error' => 'email_required']; } - $user = UserRepository::findByEmail($email); + $user = $this->userReadRepository->findByEmail($email); if (!$user || !isset($user['id'])) { return ['ok' => true]; } $userId = (int) $user['id']; - PasswordResetRepository::invalidateForUser($userId); + $this->passwordResetRepository->invalidateForUser($userId); - $code = self::generateCode(); + $code = $this->generateCode(); $codeHash = password_hash($code, PASSWORD_DEFAULT); $expiresAt = gmdate('Y-m-d H:i:s', time() + (self::EXPIRY_MINUTES * 60)); - $resetId = PasswordResetRepository::create($userId, $codeHash, $expiresAt); + $resetId = $this->passwordResetRepository->create($userId, $codeHash, $expiresAt); if (!$resetId) { return ['ok' => false, 'error' => 'create_failed']; } @@ -65,12 +73,12 @@ class PasswordResetService 'verify_url' => $verifyUrl, 'greeting' => $greeting, ]; - MailService::sendTemplate('reset_code', $vars, $email, $subject, $locale); + $this->mailService->sendTemplate('reset_code', $vars, $email, $subject, $locale); return ['ok' => true]; } - public static function verifyCode(string $email, string $code): array + public function verifyCode(string $email, string $code): array { $email = trim($email); $code = trim($code); @@ -78,12 +86,12 @@ class PasswordResetService return ['ok' => false, 'error' => 'invalid']; } - $user = UserRepository::findByEmail($email); + $user = $this->userReadRepository->findByEmail($email); if (!$user || !isset($user['id'])) { return ['ok' => false, 'error' => 'invalid']; } - $reset = PasswordResetRepository::findActiveByUserId((int) $user['id']); + $reset = $this->passwordResetRepository->findActiveByUserId((int) $user['id']); if (!$reset || !isset($reset['id'])) { return ['ok' => false, 'error' => 'invalid']; } @@ -95,16 +103,16 @@ class PasswordResetService $hash = (string) ($reset['code_hash'] ?? ''); if ($hash === '' || !password_verify($code, $hash)) { - PasswordResetRepository::incrementAttempts((int) $reset['id']); + $this->passwordResetRepository->incrementAttempts((int) $reset['id']); return ['ok' => false, 'error' => 'invalid']; } return ['ok' => true, 'reset_id' => (int) $reset['id'], 'user_id' => (int) $user['id']]; } - public static function resetPassword(int $resetId, string $password, string $password2): array + public function resetPassword(int $resetId, string $password, string $password2): array { - $reset = PasswordResetRepository::findById($resetId); + $reset = $this->passwordResetRepository->findById($resetId); if (!$reset || !isset($reset['id'])) { return ['ok' => false, 'errors' => [t('Reset request not found')]]; } @@ -130,17 +138,17 @@ class PasswordResetService return ['ok' => false, 'errors' => [t('Reset request not found')]]; } - $result = UserService::resetPassword($userId, $password, $password2); + $result = $this->userPasswordService->resetPassword($userId, $password, $password2); if (!($result['ok'] ?? false)) { return $result; } - PasswordResetRepository::markUsed($resetId); - RememberMeService::forgetAllForUser($userId); + $this->passwordResetRepository->markUsed($resetId); + $this->rememberMeService->forgetAllForUser($userId); return ['ok' => true]; } - private static function generateCode(): string + private function generateCode(): string { $max = (10 ** self::CODE_LENGTH) - 1; $code = (string) random_int(0, $max); diff --git a/lib/Service/Auth/RememberMeService.php b/lib/Service/Auth/RememberMeService.php index 797a368..5bad78a 100644 --- a/lib/Service/Auth/RememberMeService.php +++ b/lib/Service/Auth/RememberMeService.php @@ -2,34 +2,45 @@ namespace MintyPHP\Service\Auth; -use MintyPHP\Session; -use MintyPHP\Repository\Auth\RememberTokenRepository; -use MintyPHP\Repository\User\UserRepository; +use MintyPHP\Auth; use MintyPHP\I18n; -use MintyPHP\Service\Access\PermissionService; -use MintyPHP\Service\Auth\AuthService; +use MintyPHP\Repository\Auth\RememberTokenRepository; +use MintyPHP\Repository\User\UserReadRepository; +use MintyPHP\Repository\User\UserWriteRepository; +use MintyPHP\Service\User\UserTenantContextService; +use MintyPHP\Session; class RememberMeService { private const COOKIE_NAME = 'remember'; private const LIFETIME_DAYS = 30; - public static function rememberUser(int $userId): void + public function __construct( + private readonly UserReadRepository $userReadRepository, + private readonly UserWriteRepository $userWriteRepository, + private readonly RememberTokenRepository $rememberTokenRepository, + private readonly UserTenantContextService $userTenantContextService, + private readonly AuthPermissionGateway $permissionGateway, + private readonly AuthSavedFilterGateway $savedFilterGateway + ) { + } + + public function rememberUser(int $userId): void { $selector = bin2hex(random_bytes(12)); $token = bin2hex(random_bytes(32)); $tokenHash = hash('sha256', $token); - $expiresAt = gmdate('Y-m-d H:i:s', time() + self::lifetimeSeconds()); - RememberTokenRepository::create($userId, $selector, $tokenHash, $expiresAt); - self::setCookie($selector, $token); + $expiresAt = gmdate('Y-m-d H:i:s', time() + $this->lifetimeSeconds()); + $this->rememberTokenRepository->create($userId, $selector, $tokenHash, $expiresAt); + $this->setCookie($selector, $token); } - public static function autoLoginFromCookie(): bool + public function autoLoginFromCookie(): bool { if (!empty($_SESSION['user']['id'])) { return false; } - $value = $_COOKIE[self::cookieName()] ?? ''; + $value = $_COOKIE[$this->cookieName()] ?? ''; if ($value === '' || strpos($value, ':') === false) { return false; } @@ -37,40 +48,40 @@ class RememberMeService $selector = trim($selector); $token = trim($token); if ($selector === '' || $token === '') { - self::clearCookie(); + $this->clearCookie(); return false; } - $record = RememberTokenRepository::findBySelector($selector); + $record = $this->rememberTokenRepository->findBySelector($selector); if (!$record) { - self::clearCookie(); + $this->clearCookie(); return false; } $expiresAt = (string) ($record['expires_at'] ?? ''); if ($expiresAt !== '' && strtotime($expiresAt . ' UTC') <= time()) { - RememberTokenRepository::deleteById((int) $record['id']); - self::clearCookie(); + $this->rememberTokenRepository->deleteById((int) $record['id']); + $this->clearCookie(); return false; } $hash = (string) ($record['token_hash'] ?? ''); if ($hash === '' || !hash_equals($hash, hash('sha256', $token))) { - RememberTokenRepository::deleteById((int) $record['id']); - self::clearCookie(); + $this->rememberTokenRepository->deleteById((int) $record['id']); + $this->clearCookie(); return false; } $userId = (int) ($record['user_id'] ?? 0); if ($userId <= 0) { - self::clearCookie(); + $this->clearCookie(); return false; } - $user = UserRepository::find($userId); + $user = $this->userReadRepository->find($userId); if (!$user || empty($user['id']) || !($user['active'] ?? 1)) { - RememberTokenRepository::deleteById((int) $record['id']); - self::clearCookie(); + $this->rememberTokenRepository->deleteById((int) $record['id']); + $this->clearCookie(); return false; } @@ -81,59 +92,60 @@ class RememberMeService } $userId = (int) $user['id']; if ($userId > 0) { - PermissionService::getUserPermissions($userId, true); - AuthService::loadTenantDataIntoSession($userId); + $this->permissionGateway->warmUserPermissions($userId, true); + $this->loadTenantDataIntoSession($userId); if (!empty($_SESSION['no_active_tenant'])) { // Enforce the same tenant policy as interactive login. - AuthService::logout(); + $this->forgetCurrentUser(); + Auth::logout(); return false; } - UserRepository::updateLastLogin($userId, 'local'); + $this->userWriteRepository->updateLastLogin($userId, 'local'); } $newToken = bin2hex(random_bytes(32)); $newHash = hash('sha256', $newToken); - $newExpires = gmdate('Y-m-d H:i:s', time() + self::lifetimeSeconds()); - RememberTokenRepository::updateToken((int) $record['id'], $newHash, $newExpires); - self::setCookie($selector, $newToken); + $newExpires = gmdate('Y-m-d H:i:s', time() + $this->lifetimeSeconds()); + $this->rememberTokenRepository->updateToken((int) $record['id'], $newHash, $newExpires); + $this->setCookie($selector, $newToken); return true; } - public static function forgetCurrentUser(): void + public function forgetCurrentUser(): void { - $value = $_COOKIE[self::cookieName()] ?? ''; + $value = $_COOKIE[$this->cookieName()] ?? ''; if ($value !== '' && strpos($value, ':') !== false) { [$selector] = explode(':', $value, 2); $selector = trim($selector); if ($selector !== '') { - $record = RememberTokenRepository::findBySelector($selector); + $record = $this->rememberTokenRepository->findBySelector($selector); if ($record && isset($record['id'])) { - RememberTokenRepository::deleteById((int) $record['id']); + $this->rememberTokenRepository->deleteById((int) $record['id']); } } } - self::clearCookie(); + $this->clearCookie(); } - public static function forgetAllForUser(int $userId): void + public function forgetAllForUser(int $userId): void { - RememberTokenRepository::deleteByUserId($userId); + $this->rememberTokenRepository->deleteByUserId($userId); } - public static function expireAllTokensByAdmin(): int + public function expireAllTokensByAdmin(): int { - return RememberTokenRepository::expireAllByAdmin(); + return $this->rememberTokenRepository->expireAllByAdmin(); } - private static function setCookie(string $selector, string $token): void + private function setCookie(string $selector, string $token): void { $value = $selector . ':' . $token; - $expires = time() + self::lifetimeSeconds(); + $expires = time() + $this->lifetimeSeconds(); $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'); - setcookie(self::cookieName(), $value, [ + setcookie($this->cookieName(), $value, [ 'expires' => $expires, 'path' => '/', 'secure' => $secure, @@ -142,11 +154,11 @@ class RememberMeService ]); } - private static function clearCookie(): void + private function clearCookie(): void { $secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https'); - setcookie(self::cookieName(), '', [ + setcookie($this->cookieName(), '', [ 'expires' => time() - 3600, 'path' => '/', 'secure' => $secure, @@ -155,14 +167,41 @@ class RememberMeService ]); } - private static function lifetimeSeconds(): int + private function lifetimeSeconds(): int { return self::LIFETIME_DAYS * 24 * 60 * 60; } - private static function cookieName(): string + private function cookieName(): string { $name = getenv('REMEMBER_COOKIE_NAME') ?: self::COOKIE_NAME; return trim($name) !== '' ? $name : self::COOKIE_NAME; } + + private function loadTenantDataIntoSession(int $userId): void + { + if ($userId <= 0) { + return; + } + + $availableTenants = $this->userTenantContextService->getAvailableTenants($userId); + $_SESSION['available_tenants'] = $availableTenants; + $_SESSION['available_departments_by_tenant'] = $this->userTenantContextService->getAvailableDepartmentsByTenant($userId); + $_SESSION['address_book_saved_filters'] = $this->savedFilterGateway->listAddressBookFilters($userId); + + if (!$availableTenants) { + $_SESSION['no_active_tenant'] = true; + unset($_SESSION['current_tenant']); + return; + } + $_SESSION['no_active_tenant'] = false; + + $currentTenant = $this->userTenantContextService->getCurrentTenant($userId); + if (!$currentTenant) { + $currentTenant = $availableTenants[0]; + } + if ($currentTenant) { + $_SESSION['current_tenant'] = $currentTenant; + } + } } diff --git a/lib/Service/Auth/SsoUserLinkService.php b/lib/Service/Auth/SsoUserLinkService.php index f15154c..6cf06a5 100644 --- a/lib/Service/Auth/SsoUserLinkService.php +++ b/lib/Service/Auth/SsoUserLinkService.php @@ -3,15 +3,25 @@ namespace MintyPHP\Service\Auth; use MintyPHP\Repository\Tenant\UserTenantRepository; -use MintyPHP\Repository\User\UserExternalIdentityRepository; -use MintyPHP\Repository\User\UserRepository; -use MintyPHP\Service\Settings\SettingService; -use MintyPHP\Service\User\UserAvatarService; -use MintyPHP\Service\User\UserService; +use MintyPHP\Repository\User\UserReadRepository; +use MintyPHP\Repository\User\UserWriteRepository; +use MintyPHP\Service\User\UserAssignmentService; class SsoUserLinkService { - public static function linkOrProvisionMicrosoftUser(array $tenant, array $claims): array + public function __construct( + private readonly UserReadRepository $userReadRepository, + private readonly UserWriteRepository $userWriteRepository, + private readonly UserAssignmentService $userAssignmentService, + private readonly UserTenantRepository $userTenantRepository, + private readonly AuthSettingsGateway $settingsGateway, + private readonly AuthTenantSsoGateway $tenantSsoGateway, + private readonly AuthAvatarGateway $avatarGateway, + private readonly AuthExternalIdentityGateway $externalIdentityGateway + ) { + } + + public function linkOrProvisionMicrosoftUser(array $tenant, array $claims): array { $tenantId = (int) ($tenant['id'] ?? 0); $tid = trim((string) ($claims['tid'] ?? '')); @@ -24,14 +34,15 @@ class SsoUserLinkService return ['ok' => false, 'error' => 'identity_invalid']; } - $identity = UserExternalIdentityRepository::findByProviderTidOid( - TenantSsoService::PROVIDER_MICROSOFT, + $providerKey = $this->tenantSsoGateway->microsoftProviderKey(); + $identity = $this->externalIdentityGateway->findByProviderTidOid( + $providerKey, $tid, $oid ); if (!$identity) { - $identity = UserExternalIdentityRepository::findByProviderIssuerSubject( - TenantSsoService::PROVIDER_MICROSOFT, + $identity = $this->externalIdentityGateway->findByProviderIssuerSubject( + $providerKey, $issuer, $subject ); @@ -39,15 +50,15 @@ class SsoUserLinkService if ($identity) { $userId = (int) ($identity['user_id'] ?? 0); - $user = UserRepository::find($userId); + $user = $this->userReadRepository->find($userId); if (!$user || empty($user['active'])) { return ['ok' => false, 'error' => 'user_inactive']; } - $tenantIds = array_values(array_map('intval', UserTenantRepository::listTenantIdsByUserId($userId))); + $tenantIds = array_values(array_map('intval', $this->userTenantRepository->listTenantIdsByUserId($userId))); if (!in_array($tenantId, $tenantIds, true)) { return ['ok' => false, 'error' => 'tenant_not_assigned']; } - self::syncProfileFromMicrosoft($userId, $claims); + $this->syncProfileFromMicrosoft($userId, $claims); return ['ok' => true, 'user_id' => $userId, 'created' => false]; } @@ -55,16 +66,16 @@ class SsoUserLinkService return ['ok' => false, 'error' => 'email_missing']; } - $user = UserRepository::findByEmail($email); + $user = $this->userReadRepository->findByEmail($email); if ($user) { $userId = (int) ($user['id'] ?? 0); if ($userId <= 0 || empty($user['active'])) { return ['ok' => false, 'error' => 'user_inactive']; } - self::ensureTenantAssignment($userId, $tenantId); - self::createIdentityLink($userId, $tid, $oid, $issuer, $subject, $email); - self::syncProfileFromMicrosoft($userId, $claims); + $this->ensureTenantAssignment($userId, $tenantId); + $this->createIdentityLink($userId, $tid, $oid, $issuer, $subject, $email); + $this->syncProfileFromMicrosoft($userId, $claims); return ['ok' => true, 'user_id' => $userId, 'created' => false]; } @@ -80,14 +91,14 @@ class SsoUserLinkService $firstName = ucfirst(explode('@', $email, 2)[0]); } - $createdId = UserRepository::create([ + $createdId = $this->userWriteRepository->create([ 'first_name' => $firstName, 'last_name' => $lastName, 'email' => $email, - 'password' => self::randomPassword(), - 'locale' => self::normalizeLocale((string) ($claims['preferred_language'] ?? '')), + 'password' => $this->randomPassword(), + 'locale' => $this->normalizeLocale((string) ($claims['preferred_language'] ?? '')), 'totp_secret' => '', - 'theme' => self::resolveInitialTheme(SettingService::getAppTheme()), + 'theme' => $this->resolveInitialTheme($this->settingsGateway->getAppTheme()), 'primary_tenant_id' => $tenantId, 'current_tenant_id' => $tenantId, 'active' => 1, @@ -101,31 +112,31 @@ class SsoUserLinkService } $userId = (int) $createdId; - UserRepository::setEmailVerified($userId); - UserService::syncTenants($userId, [$tenantId]); - $defaultRoleId = SettingService::getDefaultRoleId(); + $this->userWriteRepository->setEmailVerified($userId); + $this->userAssignmentService->syncTenants($userId, [$tenantId]); + $defaultRoleId = $this->settingsGateway->getDefaultRoleId(); if ($defaultRoleId) { - UserService::syncRoles($userId, [$defaultRoleId]); + $this->userAssignmentService->syncRoles($userId, [$defaultRoleId]); } - $defaultDepartmentId = SettingService::getDefaultDepartmentId(); + $defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId(); if ($defaultDepartmentId) { - UserService::syncDepartments($userId, [$defaultDepartmentId]); + $this->userAssignmentService->syncDepartments($userId, [$defaultDepartmentId]); } - self::createIdentityLink($userId, $tid, $oid, $issuer, $subject, $email); - self::syncProfileFromMicrosoft($userId, $claims); + $this->createIdentityLink($userId, $tid, $oid, $issuer, $subject, $email); + $this->syncProfileFromMicrosoft($userId, $claims); return ['ok' => true, 'user_id' => $userId, 'created' => true]; } - private static function syncProfileFromMicrosoft(int $userId, array $claims): void + private function syncProfileFromMicrosoft(int $userId, array $claims): void { if ($userId <= 0 || empty($claims['sync_profile_on_login'])) { return; } - $syncFields = TenantSsoService::normalizeProfileSyncFields($claims['sync_profile_fields'] ?? []); + $syncFields = $this->tenantSsoGateway->normalizeProfileSyncFields($claims['sync_profile_fields'] ?? []); if (!$syncFields) { - $syncFields = TenantSsoService::defaultProfileSyncFields(); + $syncFields = $this->tenantSsoGateway->defaultProfileSyncFields(); } if (!$syncFields) { return; @@ -156,18 +167,18 @@ class SsoUserLinkService } if (!$updates) { if ($syncAvatar) { - self::syncAvatarFromMicrosoft($userId, $claims); + $this->syncAvatarFromMicrosoft($userId, $claims); } return; } - UserRepository::updateProfileFieldsFromSso($userId, $updates); + $this->userWriteRepository->updateProfileFieldsFromSso($userId, $updates); if ($syncAvatar) { - self::syncAvatarFromMicrosoft($userId, $claims); + $this->syncAvatarFromMicrosoft($userId, $claims); } } - private static function syncAvatarFromMicrosoft(int $userId, array $claims): void + private function syncAvatarFromMicrosoft(int $userId, array $claims): void { $avatarBase64 = trim((string) ($claims['graph_avatar_data_base64'] ?? '')); if ($avatarBase64 === '') { @@ -184,29 +195,29 @@ class SsoUserLinkService return; } - $user = UserRepository::find($userId); + $user = $this->userReadRepository->find($userId); if (!$user) { return; } $userUuid = (string) ($user['uuid'] ?? ''); - if (!UserAvatarService::isValidUuid($userUuid)) { + if (!$this->avatarGateway->isValidUuid($userUuid)) { return; } // Keep avatar current with Microsoft profile photo on each login. - UserAvatarService::saveBinary($userUuid, $binary, $avatarMime); + $this->avatarGateway->saveBinary($userUuid, $binary, $avatarMime); } - private static function ensureTenantAssignment(int $userId, int $tenantId): void + private function ensureTenantAssignment(int $userId, int $tenantId): void { - $tenantIds = array_values(array_unique(array_map('intval', UserTenantRepository::listTenantIdsByUserId($userId)))); + $tenantIds = array_values(array_unique(array_map('intval', $this->userTenantRepository->listTenantIdsByUserId($userId)))); if (!in_array($tenantId, $tenantIds, true)) { $tenantIds[] = $tenantId; - UserService::syncTenants($userId, $tenantIds); + $this->userAssignmentService->syncTenants($userId, $tenantIds); } } - private static function createIdentityLink( + private function createIdentityLink( int $userId, string $tid, string $oid, @@ -215,9 +226,10 @@ class SsoUserLinkService string $email ): void { try { - UserExternalIdentityRepository::createLink([ + $providerKey = $this->tenantSsoGateway->microsoftProviderKey(); + $this->externalIdentityGateway->createLink([ 'user_id' => $userId, - 'provider' => TenantSsoService::PROVIDER_MICROSOFT, + 'provider' => $providerKey, 'oid' => $oid, 'tid' => $tid, 'issuer' => $issuer, @@ -229,7 +241,7 @@ class SsoUserLinkService } } - private static function normalizeLocale(string $locale): ?string + private function normalizeLocale(string $locale): ?string { $locale = strtolower(trim($locale)); if ($locale === '') { @@ -245,12 +257,12 @@ class SsoUserLinkService return $locale; } - private static function randomPassword(): string + private function randomPassword(): string { return 'sso-' . bin2hex(random_bytes(24)) . '-A1!'; } - private static function resolveInitialTheme(?string $theme): string + private function resolveInitialTheme(?string $theme): string { $theme = strtolower(trim((string) ($theme ?? ''))); $themes = function_exists('appThemes') ? appThemes() : ['light' => 'Light']; diff --git a/lib/Service/Auth/TenantSsoService.php b/lib/Service/Auth/TenantSsoService.php index e6c7d93..0977037 100644 --- a/lib/Service/Auth/TenantSsoService.php +++ b/lib/Service/Auth/TenantSsoService.php @@ -3,13 +3,11 @@ namespace MintyPHP\Service\Auth; use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepository; -use MintyPHP\Repository\Tenant\TenantRepository; -use MintyPHP\Service\Settings\SettingService; -use MintyPHP\Support\Crypto; class TenantSsoService { public const PROVIDER_MICROSOFT = 'microsoft'; + private const PROFILE_SYNC_FIELD_OPTIONS = [ 'first_name' => 'Sync first name', 'last_name' => 'Sync last name', @@ -17,9 +15,23 @@ class TenantSsoService 'mobile' => 'Sync mobile', 'avatar' => 'Sync avatar image', ]; + private const DEFAULT_PROFILE_SYNC_FIELDS = ['first_name', 'last_name']; - public static function findTenantByLoginSlug(string $slug): ?array + public function __construct( + private readonly AuthTenantGateway $tenantGateway, + private readonly TenantMicrosoftAuthRepository $tenantMicrosoftAuthRepository, + private readonly AuthSettingsGateway $settingsGateway, + private readonly AuthCryptoGateway $cryptoGateway + ) { + } + + public function microsoftProviderKey(): string + { + return self::PROVIDER_MICROSOFT; + } + + public function findTenantByLoginSlug(string $slug): ?array { $slug = trim(strtolower($slug)); if ($slug === '') { @@ -27,18 +39,18 @@ class TenantSsoService } if (preg_match('/^[a-f0-9-]{36}$/', $slug)) { - $tenant = TenantRepository::findByUuid($slug); + $tenant = $this->tenantGateway->findByUuid($slug); if ($tenant && (string) ($tenant['status'] ?? 'active') === 'active') { return $tenant; } } $matches = []; - foreach (TenantRepository::list() as $tenant) { + foreach ($this->tenantGateway->list() as $tenant) { if ((string) ($tenant['status'] ?? 'active') !== 'active') { continue; } - if (self::slugify((string) ($tenant['description'] ?? '')) === $slug) { + if ($this->slugify((string) ($tenant['description'] ?? '')) === $slug) { $matches[] = $tenant; } } @@ -50,70 +62,72 @@ class TenantSsoService return $matches[0]; } - public static function tenantLoginSlug(array $tenant): string + public function tenantLoginSlug(array $tenant): string { - $slug = self::slugify((string) ($tenant['description'] ?? '')); + $slug = $this->slugify((string) ($tenant['description'] ?? '')); if ($slug !== '') { return $slug; } + return strtolower((string) ($tenant['uuid'] ?? '')); } - public static function getTenantMicrosoftAuth(int $tenantId): array + public function getTenantMicrosoftAuth(int $tenantId): array { - $row = TenantMicrosoftAuthRepository::findByTenantId($tenantId) ?? []; + $row = $this->tenantMicrosoftAuthRepository->findByTenantId($tenantId) ?? []; $syncProfileOnLogin = !empty($row['sync_profile_on_login']); - $syncFields = self::normalizeProfileSyncFields($row['sync_profile_fields'] ?? []); + $syncFields = $this->normalizeProfileSyncFields($row['sync_profile_fields'] ?? []); if ($syncProfileOnLogin && !$syncFields) { - $syncFields = self::defaultProfileSyncFields(); + $syncFields = $this->defaultProfileSyncFields(); } return [ 'enabled' => !empty($row['enabled']), 'enforce_microsoft_login' => !empty($row['enforce_microsoft_login']), 'sync_profile_on_login' => $syncProfileOnLogin, - 'sync_profile_fields' => self::profileSyncFieldsCsv($syncFields), + 'sync_profile_fields' => $this->profileSyncFieldsCsv($syncFields), 'sync_profile_fields_list' => $syncFields, 'entra_tenant_id' => strtolower(trim((string) ($row['entra_tenant_id'] ?? ''))), - 'allowed_domains' => self::normalizeDomains((string) ($row['allowed_domains'] ?? '')), + 'allowed_domains' => $this->normalizeDomains((string) ($row['allowed_domains'] ?? '')), 'use_shared_app' => !array_key_exists('use_shared_app', $row) || !empty($row['use_shared_app']), 'client_id_override' => trim((string) ($row['client_id_override'] ?? '')), 'client_secret_override_enc' => trim((string) ($row['client_secret_override_enc'] ?? '')), ]; } - public static function buildMicrosoftUiState(int $tenantId, array $input = []): array + public function buildMicrosoftUiState(int $tenantId, array $input = []): array { - $existing = self::getTenantMicrosoftAuth($tenantId); - $state = self::normalizeMicrosoftInputState($input, $existing); - $configState = self::evaluateMicrosoftConfigState($state); + $existing = $this->getTenantMicrosoftAuth($tenantId); + $state = $this->normalizeMicrosoftInputState($input, $existing); + $configState = $this->evaluateMicrosoftConfigState($state); return [ 'enabled' => $state['enabled'], 'config_complete' => !$state['enabled'] || $configState['complete'], 'config_error_code' => (string) ($configState['error_code'] ?? ''), - 'config_error_label' => self::microsoftConfigErrorLabel((string) ($configState['error_code'] ?? '')), + 'config_error_label' => $this->microsoftConfigErrorLabel((string) ($configState['error_code'] ?? '')), 'password_mode' => $state['enabled'] && $state['enforce_microsoft_login'] ? 'microsoft_only' : 'local_and_microsoft', 'credential_source' => $state['use_shared_app'] ? 'shared' : 'override', 'sync_needs_graph' => $state['sync_profile_on_login'] - && self::profileSyncFieldsRequireGraph($state['sync_profile_fields']), + && $this->profileSyncFieldsRequireGraph($state['sync_profile_fields']), ]; } - public static function isLocalPasswordLoginAllowed(int $tenantId): bool + public function isLocalPasswordLoginAllowed(int $tenantId): bool { - $auth = self::getTenantMicrosoftAuth($tenantId); + $auth = $this->getTenantMicrosoftAuth($tenantId); if (!$auth['enabled']) { return true; } + return !$auth['enforce_microsoft_login']; } - public static function resolveTenantLoginMethods(int $tenantId): array + public function resolveTenantLoginMethods(int $tenantId): array { - $tenant = TenantRepository::find($tenantId); + $tenant = $this->tenantGateway->findById($tenantId); if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') { return [ 'local' => false, @@ -122,9 +136,10 @@ class TenantSsoService ]; } - $configResult = self::getEffectiveMicrosoftProviderConfig($tenant); + $configResult = $this->getEffectiveMicrosoftProviderConfig($tenant); + return [ - 'local' => self::isLocalPasswordLoginAllowed($tenantId), + 'local' => $this->isLocalPasswordLoginAllowed($tenantId), 'microsoft' => !empty($configResult['ok']), 'microsoft_reason' => $configResult['ok'] ?? false ? '' @@ -132,14 +147,14 @@ class TenantSsoService ]; } - public static function getEffectiveMicrosoftProviderConfig(array $tenant): array + public function getEffectiveMicrosoftProviderConfig(array $tenant): array { $tenantId = (int) ($tenant['id'] ?? 0); if ($tenantId <= 0) { return ['ok' => false, 'error' => 'tenant_not_found']; } - $auth = self::getTenantMicrosoftAuth($tenantId); + $auth = $this->getTenantMicrosoftAuth($tenantId); if (!$auth['enabled']) { return ['ok' => false, 'error' => 'sso_disabled']; } @@ -152,9 +167,10 @@ class TenantSsoService $useSharedApp = $auth['use_shared_app']; $clientId = ''; $clientSecret = ''; + if ($useSharedApp) { - $clientId = (string) (SettingService::getMicrosoftSharedClientId() ?? ''); - $clientSecret = (string) (SettingService::getMicrosoftSharedClientSecret() ?? ''); + $clientId = $this->settingsGateway->getMicrosoftSharedClientId(); + $clientSecret = $this->settingsGateway->getMicrosoftSharedClientSecret(); if (trim($clientId) === '' || trim($clientSecret) === '') { return ['ok' => false, 'error' => 'shared_credentials_missing']; } @@ -168,7 +184,7 @@ class TenantSsoService } if ($auth['client_secret_override_enc'] !== '') { try { - $clientSecret = Crypto::decryptString($auth['client_secret_override_enc']); + $clientSecret = $this->cryptoGateway->decryptString($auth['client_secret_override_enc']); } catch (\Throwable $exception) { return ['ok' => false, 'error' => 'secret_invalid']; } @@ -180,9 +196,6 @@ class TenantSsoService $clientId = trim($clientId); $clientSecret = trim($clientSecret); - if ($clientId === '' || $clientSecret === '') { - return ['ok' => false, 'error' => 'client_credentials_missing']; - } return [ 'ok' => true, @@ -196,16 +209,16 @@ class TenantSsoService 'sync_profile_on_login' => $auth['sync_profile_on_login'], 'sync_profile_fields' => $auth['sync_profile_fields_list'], 'sync_profile_needs_graph' => $auth['sync_profile_on_login'] - && self::profileSyncFieldsRequireGraph($auth['sync_profile_fields_list']), - 'authority' => SettingService::getMicrosoftAuthority(), + && $this->profileSyncFieldsRequireGraph($auth['sync_profile_fields_list']), + 'authority' => $this->settingsGateway->getMicrosoftAuthority(), 'use_shared_app' => $useSharedApp, ], ]; } - public static function saveTenantMicrosoftAuth(int $tenantId, array $input): array + public function saveTenantMicrosoftAuth(int $tenantId, array $input): array { - $tenant = TenantRepository::find($tenantId); + $tenant = $this->tenantGateway->findById($tenantId); if (!$tenant) { return ['ok' => false, 'errors' => [t('Tenant not found')]]; } @@ -213,13 +226,13 @@ class TenantSsoService $enabled = !empty($input['microsoft_enabled']); $enforce = !empty($input['enforce_microsoft_login']); $entraTenantId = strtolower(trim((string) ($input['entra_tenant_id'] ?? ''))); - $allowedDomains = self::normalizeDomains((string) ($input['allowed_domains'] ?? '')); + $allowedDomains = $this->normalizeDomains((string) ($input['allowed_domains'] ?? '')); $syncProfileOnLogin = !empty($input['sync_profile_on_login']); - $syncProfileFields = self::normalizeProfileSyncFields($input['sync_profile_fields'] ?? []); + $syncProfileFields = $this->normalizeProfileSyncFields($input['sync_profile_fields'] ?? []); if ($syncProfileOnLogin && !$syncProfileFields) { - $syncProfileFields = self::defaultProfileSyncFields(); + $syncProfileFields = $this->defaultProfileSyncFields(); } - $syncProfileFieldsCsv = self::profileSyncFieldsCsv($syncProfileFields); + $syncProfileFieldsCsv = $this->profileSyncFieldsCsv($syncProfileFields); $useSharedApp = !empty($input['use_shared_app']); $clientIdOverride = trim((string) ($input['client_id_override'] ?? '')); $clientSecretOverride = trim((string) ($input['client_secret_override'] ?? '')); @@ -227,16 +240,16 @@ class TenantSsoService $errors = []; - $existing = self::getTenantMicrosoftAuth($tenantId); + $existing = $this->getTenantMicrosoftAuth($tenantId); $clientSecretOverrideEnc = (string) ($existing['client_secret_override_enc'] ?? ''); if ($clearSecret) { $clientSecretOverrideEnc = ''; } elseif ($clientSecretOverride !== '') { - if (!Crypto::isConfigured()) { + if (!$this->cryptoGateway->isConfigured()) { $errors[] = t('APP_CRYPTO_KEY is missing or invalid'); } else { try { - $clientSecretOverrideEnc = Crypto::encryptString($clientSecretOverride); + $clientSecretOverrideEnc = $this->cryptoGateway->encryptString($clientSecretOverride); } catch (\Throwable $exception) { $errors[] = t('Client secret could not be encrypted'); } @@ -253,9 +266,9 @@ class TenantSsoService 'client_id_override' => $clientIdOverride, 'client_secret_override_enc' => $clientSecretOverrideEnc, ]; - $configState = self::evaluateMicrosoftConfigState($stateForValidation); + $configState = $this->evaluateMicrosoftConfigState($stateForValidation); if ($enabled && !($configState['complete'] ?? false)) { - $label = self::microsoftConfigErrorLabel((string) ($configState['error_code'] ?? '')); + $label = $this->microsoftConfigErrorLabel((string) ($configState['error_code'] ?? '')); if ($label !== '') { $errors[] = $label; } @@ -268,7 +281,7 @@ class TenantSsoService return ['ok' => false, 'errors' => $errors]; } - $saved = TenantMicrosoftAuthRepository::upsertByTenantId($tenantId, [ + $saved = $this->tenantMicrosoftAuthRepository->upsertByTenantId($tenantId, [ 'enabled' => $enabled, 'enforce_microsoft_login' => $enabled ? $enforce : false, 'sync_profile_on_login' => $enabled ? $syncProfileOnLogin : false, @@ -287,7 +300,7 @@ class TenantSsoService return ['ok' => true]; } - public static function isEmailDomainAllowed(string $email, array $allowedDomains): bool + public function isEmailDomainAllowed(string $email, array $allowedDomains): bool { $allowedDomains = array_values(array_filter(array_map( static fn ($domain): string => strtolower(trim((string) $domain)), @@ -302,20 +315,21 @@ class TenantSsoService if (count($parts) !== 2) { return false; } + return in_array($parts[1], $allowedDomains, true); } - public static function profileSyncFieldOptions(): array + public function profileSyncFieldOptions(): array { return self::PROFILE_SYNC_FIELD_OPTIONS; } - public static function defaultProfileSyncFields(): array + public function defaultProfileSyncFields(): array { return self::DEFAULT_PROFILE_SYNC_FIELDS; } - public static function normalizeProfileSyncFields($raw): array + public function normalizeProfileSyncFields($raw): array { if (is_string($raw)) { $raw = preg_split('/[\s,;]+/', $raw) ?: []; @@ -336,21 +350,21 @@ class TenantSsoService return array_keys($normalized); } - public static function profileSyncFieldsCsv(array $fields): string + public function profileSyncFieldsCsv(array $fields): string { - $fields = self::normalizeProfileSyncFields($fields); + $fields = $this->normalizeProfileSyncFields($fields); return implode(',', $fields); } - public static function profileSyncFieldsRequireGraph(array $fields): bool + public function profileSyncFieldsRequireGraph(array $fields): bool { - $fields = self::normalizeProfileSyncFields($fields); + $fields = $this->normalizeProfileSyncFields($fields); return in_array('phone', $fields, true) || in_array('mobile', $fields, true) || in_array('avatar', $fields, true); } - private static function normalizeDomains(string $raw): string + private function normalizeDomains(string $raw): string { $parts = preg_split('/[,\n;\r]+/', $raw) ?: []; $domains = []; @@ -369,7 +383,7 @@ class TenantSsoService return implode(',', $domains); } - private static function normalizeMicrosoftInputState(array $input, array $existing): array + private function normalizeMicrosoftInputState(array $input, array $existing): array { $enabled = array_key_exists('microsoft_enabled', $input) ? !empty($input['microsoft_enabled']) @@ -381,10 +395,10 @@ class TenantSsoService ? !empty($input['sync_profile_on_login']) : !empty($existing['sync_profile_on_login']); $syncProfileFields = array_key_exists('sync_profile_fields', $input) - ? self::normalizeProfileSyncFields($input['sync_profile_fields'] ?? []) - : self::normalizeProfileSyncFields($existing['sync_profile_fields_list'] ?? []); + ? $this->normalizeProfileSyncFields($input['sync_profile_fields'] ?? []) + : $this->normalizeProfileSyncFields($existing['sync_profile_fields_list'] ?? []); if ($syncProfileOnLogin && !$syncProfileFields) { - $syncProfileFields = self::defaultProfileSyncFields(); + $syncProfileFields = $this->defaultProfileSyncFields(); } $entraTenantId = array_key_exists('entra_tenant_id', $input) @@ -417,7 +431,7 @@ class TenantSsoService ]; } - private static function evaluateMicrosoftConfigState(array $state): array + private function evaluateMicrosoftConfigState(array $state): array { if (empty($state['enabled'])) { return ['complete' => true, 'error_code' => '']; @@ -429,8 +443,8 @@ class TenantSsoService } if (!empty($state['use_shared_app'])) { - $sharedClientId = trim((string) (SettingService::getMicrosoftSharedClientId() ?? '')); - $sharedClientSecret = trim((string) (SettingService::getMicrosoftSharedClientSecret() ?? '')); + $sharedClientId = trim($this->settingsGateway->getMicrosoftSharedClientId()); + $sharedClientSecret = trim($this->settingsGateway->getMicrosoftSharedClientSecret()); if ($sharedClientId === '' || $sharedClientSecret === '') { return ['complete' => false, 'error_code' => 'shared_credentials_missing']; } @@ -448,7 +462,7 @@ class TenantSsoService } if ($secretEnc !== '__pending_secret__') { try { - $secret = trim((string) Crypto::decryptString($secretEnc)); + $secret = trim($this->cryptoGateway->decryptString($secretEnc)); } catch (\Throwable $exception) { return ['complete' => false, 'error_code' => 'secret_invalid']; } @@ -460,7 +474,7 @@ class TenantSsoService return ['complete' => true, 'error_code' => '']; } - private static function microsoftConfigErrorLabel(string $errorCode): string + private function microsoftConfigErrorLabel(string $errorCode): string { return match ($errorCode) { 'tenant_id_invalid' => t('Entra tenant ID is invalid'), @@ -473,7 +487,7 @@ class TenantSsoService }; } - private static function slugify(string $value): string + private function slugify(string $value): string { $value = strtolower(trim($value)); if ($value === '') { diff --git a/lib/Service/Branding/BrandingFaviconService.php b/lib/Service/Branding/BrandingFaviconService.php index 58fd499..2898f60 100644 --- a/lib/Service/Branding/BrandingFaviconService.php +++ b/lib/Service/Branding/BrandingFaviconService.php @@ -3,6 +3,7 @@ namespace MintyPHP\Service\Branding; use MintyPHP\Service\Image\ImageUploadTrait; +use MintyPHP\Service\Settings\SettingGateway; class BrandingFaviconService { @@ -18,30 +19,34 @@ class BrandingFaviconService 512 => 'web-app-manifest-512x512.png', ]; - public static function storageBase(): string + public function __construct(private readonly SettingGateway $settingGateway) + { + } + + public function storageBase(): string { return self::imageStorageBase(); } - public static function storageDir(): string + public function storageDir(): string { - return self::storageBase() . '/branding/favicon'; + return $this->storageBase() . '/branding/favicon'; } - public static function publicDir(): string + public function publicDir(): string { return rtrim(dirname(__DIR__, 3) . '/web/favicon', '/'); } - public static function hasFavicon(): bool + public function hasFavicon(): bool { - $path = self::publicDir() . '/favicon-32x32.png'; + $path = $this->publicDir() . '/favicon-32x32.png'; return is_file($path); } - public static function delete(): void + public function delete(): void { - $dir = self::storageDir(); + $dir = $this->storageDir(); if (is_dir($dir)) { $matches = glob($dir . '/*') ?: []; foreach ($matches as $file) { @@ -50,7 +55,8 @@ class BrandingFaviconService } } } - $public = self::publicDir(); + + $public = $this->publicDir(); foreach (self::SIZES as $file) { $target = $public . '/' . $file; if (is_file($target)) { @@ -59,7 +65,7 @@ class BrandingFaviconService } } - public static function saveUpload(array $file): array + public function saveUpload(array $file): array { if (empty($file) || !isset($file['tmp_name'])) { return ['ok' => false, 'error' => t('No file uploaded')]; @@ -72,7 +78,7 @@ class BrandingFaviconService } $tmpPath = $file['tmp_name']; - $mime = self::detectMime($tmpPath); + $mime = $this->detectMime($tmpPath); if ($mime !== 'image/png') { return ['ok' => false, 'error' => t('Invalid image file')]; } @@ -80,19 +86,19 @@ class BrandingFaviconService return ['ok' => false, 'error' => t('Upload failed')]; } - $dir = self::storageDir(); + $dir = $this->storageDir(); if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { return ['ok' => false, 'error' => t('Upload failed')]; } - self::delete(); + $this->delete(); $originalPath = $dir . '/original.png'; if (!move_uploaded_file($tmpPath, $originalPath)) { return ['ok' => false, 'error' => t('Upload failed')]; } @chmod($originalPath, 0644); - $publicDir = self::publicDir(); + $publicDir = $this->publicDir(); if (!is_dir($publicDir) && !mkdir($publicDir, 0755, true) && !is_dir($publicDir)) { return ['ok' => false, 'error' => t('Upload failed')]; } @@ -105,18 +111,18 @@ class BrandingFaviconService @chmod($target, 0644); } - self::updateManifest(); + $this->updateManifest(); return ['ok' => true, 'path' => $originalPath, 'mime' => $mime]; } - public static function detectMime(string $path): string + public function detectMime(string $path): string { return self::imageDetectMimeSimple($path); } - private static function updateManifest(): void + private function updateManifest(): void { - $manifestPath = self::publicDir() . '/site.webmanifest'; + $manifestPath = $this->publicDir() . '/site.webmanifest'; $data = []; if (is_file($manifestPath)) { $json = file_get_contents($manifestPath); @@ -126,10 +132,7 @@ class BrandingFaviconService } } - $title = null; - if (class_exists('MintyPHP\\Service\\Settings\\SettingService')) { - $title = \MintyPHP\Service\Settings\SettingService::getAppTitle(); - } + $title = $this->settingGateway->getAppTitle(); if ($title === null || $title === '') { $title = defined('APP_NAME') ? APP_NAME : 'IMVS'; } diff --git a/lib/Service/Branding/BrandingLogoService.php b/lib/Service/Branding/BrandingLogoService.php index b885e8d..f502f06 100644 --- a/lib/Service/Branding/BrandingLogoService.php +++ b/lib/Service/Branding/BrandingLogoService.php @@ -12,30 +12,30 @@ class BrandingLogoService private const SIZES = [64, 128, 256]; private const DEFAULT_SIZE = 128; - public static function storageBase(): string + public function storageBase(): string { return self::imageStorageBase(); } - public static function brandingDir(): string + public function brandingDir(): string { - return self::storageBase() . '/branding/logo'; + return $this->storageBase() . '/branding/logo'; } - public static function findLogoPath(?int $size = null): ?string + public function findLogoPath(?int $size = null): ?string { - $dir = self::brandingDir(); + $dir = $this->brandingDir(); if (!is_dir($dir)) { return null; } if ($size) { - $size = self::normalizeSize($size); - $variant = self::findVariantPath($dir, $size); + $size = $this->normalizeSize($size); + $variant = $this->findVariantPath($dir, $size); if ($variant) { return $variant; } } - $defaultVariant = self::findVariantPath($dir, self::DEFAULT_SIZE); + $defaultVariant = $this->findVariantPath($dir, self::DEFAULT_SIZE); if ($defaultVariant) { return $defaultVariant; } @@ -43,15 +43,15 @@ class BrandingLogoService return $original ?: null; } - public static function hasLogo(): bool + public function hasLogo(): bool { - $path = self::findLogoPath(); + $path = $this->findLogoPath(); return $path ? is_file($path) : false; } - public static function delete(): bool + public function delete(): bool { - $dir = self::brandingDir(); + $dir = $this->brandingDir(); if (!is_dir($dir)) { return true; } @@ -68,7 +68,7 @@ class BrandingLogoService return true; } - public static function saveUpload(array $file): array + public function saveUpload(array $file): array { if (empty($file) || !isset($file['tmp_name'])) { return ['ok' => false, 'error' => t('No file uploaded')]; @@ -81,7 +81,7 @@ class BrandingLogoService } $tmpPath = $file['tmp_name']; - $mime = self::detectMime($tmpPath); + $mime = $this->detectMime($tmpPath); $isSvg = self::imageIsSvgUpload($mime, $tmpPath); $ext = self::imageExtensionForMime($mime, $isSvg); if (!$ext) { @@ -91,12 +91,12 @@ class BrandingLogoService return ['ok' => false, 'error' => t('Invalid image file')]; } - $dir = self::brandingDir(); + $dir = $this->brandingDir(); if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { return ['ok' => false, 'error' => t('Upload failed')]; } - self::delete(); + $this->delete(); $originalPath = $dir . '/original.' . $ext; if (!move_uploaded_file($tmpPath, $originalPath)) { return ['ok' => false, 'error' => t('Upload failed')]; @@ -114,12 +114,12 @@ class BrandingLogoService return ['ok' => true, 'path' => $originalPath, 'mime' => $mime]; } - public static function detectMime(string $path): string + public function detectMime(string $path): string { return self::imageDetectMime($path); } - private static function normalizeSize(int $size): int + private function normalizeSize(int $size): int { if (in_array($size, self::SIZES, true)) { return $size; @@ -127,7 +127,7 @@ class BrandingLogoService return self::DEFAULT_SIZE; } - private static function findVariantPath(string $dir, int $size): ?string + private function findVariantPath(string $dir, int $size): ?string { $matches = glob($dir . '/logo-' . $size . '.*'); if (!$matches) { diff --git a/lib/Service/Branding/BrandingServicesFactory.php b/lib/Service/Branding/BrandingServicesFactory.php new file mode 100644 index 0000000..8556a2d --- /dev/null +++ b/lib/Service/Branding/BrandingServicesFactory.php @@ -0,0 +1,34 @@ +brandingLogoService ??= new BrandingLogoService(); + } + + public function createBrandingFaviconService(): BrandingFaviconService + { + return $this->brandingFaviconService ??= new BrandingFaviconService($this->createSettingGateway()); + } + + private function createSettingGateway(): SettingGateway + { + return $this->settingGateway ??= $this->settingServicesFactory()->createSettingGateway(); + } + + private function settingServicesFactory(): SettingServicesFactory + { + return $this->settingServicesFactory ??= new SettingServicesFactory(); + } +} diff --git a/lib/Service/CustomField/TenantCustomFieldService.php b/lib/Service/CustomField/TenantCustomFieldService.php index 08c9095..21bd12c 100644 --- a/lib/Service/CustomField/TenantCustomFieldService.php +++ b/lib/Service/CustomField/TenantCustomFieldService.php @@ -46,7 +46,7 @@ class TenantCustomFieldService public static function createForTenant(int $tenantId, array $input, int $currentUserId): array { - if ($tenantId <= 0 || !TenantRepository::find($tenantId)) { + if ($tenantId <= 0 || !(new TenantRepository())->find($tenantId)) { return ['ok' => false, 'errors' => [t('Tenant not found')]]; } diff --git a/lib/Service/CustomField/UserCustomFieldValueService.php b/lib/Service/CustomField/UserCustomFieldValueService.php index 59402fa..15e790d 100644 --- a/lib/Service/CustomField/UserCustomFieldValueService.php +++ b/lib/Service/CustomField/UserCustomFieldValueService.php @@ -2,12 +2,12 @@ namespace MintyPHP\Service\CustomField; -use MintyPHP\Repository\Support\RepoQuery; use MintyPHP\Repository\CustomField\TenantCustomFieldDefinitionRepository; use MintyPHP\Repository\CustomField\TenantCustomFieldOptionRepository; use MintyPHP\Repository\CustomField\UserCustomFieldValueOptionRepository; use MintyPHP\Repository\CustomField\UserCustomFieldValueRepository; -use MintyPHP\Service\Tenant\TenantScopeService; +use MintyPHP\Repository\Support\RepoQuery; +use MintyPHP\Service\User\UserScopeGateway; class UserCustomFieldValueService { @@ -119,7 +119,7 @@ class UserCustomFieldValueService * Build API-safe custom field payload grouped by tenant. * * @param array> $tenantSummaries Assignment tenant rows - * from UserService::buildAssignmentsForUser(...), each with at least id/uuid/description/status. + * from user assignment context, each with at least id/uuid/description/status. * @return array> */ public static function buildPublicValuesByTenant(int $userId, array $tenantSummaries, ?int $tenantScopeId = null): array @@ -228,9 +228,9 @@ class UserCustomFieldValueService $tenant = $orderedTenants[$tenantId]; $result[] = [ 'tenant' => [ - 'uuid' => (string) ($tenant['uuid'] ?? ''), - 'description' => (string) ($tenant['description'] ?? ''), - 'status' => (string) ($tenant['status'] ?? ''), + 'uuid' => (string) $tenant['uuid'], + 'description' => (string) $tenant['description'], + 'status' => (string) $tenant['status'], ], 'fields' => $fields, ]; @@ -406,7 +406,7 @@ class UserCustomFieldValueService public static function extractAddressBookFilterSpec(array $query, int $currentUserId): array { - $tenantIds = TenantScopeService::getUserTenantIds($currentUserId); + $tenantIds = (new UserScopeGateway())->getUserTenantIds($currentUserId); $definitions = TenantCustomFieldDefinitionRepository::listFilterableByTenantIds($tenantIds); if (!$definitions) { return ['definitions' => [], 'filters' => [], 'query' => []]; diff --git a/lib/Service/Directory/DirectoryScopeGateway.php b/lib/Service/Directory/DirectoryScopeGateway.php new file mode 100644 index 0000000..32c6158 --- /dev/null +++ b/lib/Service/Directory/DirectoryScopeGateway.php @@ -0,0 +1,64 @@ +tenantScopeService()->hasGlobalAccess($userId); + } + + public function getUserTenantIds(int $userId): array + { + return $this->tenantScopeService()->getUserTenantIds($userId); + } + + public function isStrict(): bool + { + return $this->tenantScopeService()->isStrict(); + } + + public function canAccess(string $resourceType, int $resourceId, int $userId): bool + { + return $this->tenantScopeService()->canAccess($resourceType, $resourceId, $userId); + } + + public function resourceBelongsToTenant(string $resourceType, int $resourceId, int $tenantId): bool + { + return $this->tenantScopeService()->resourceBelongsToTenant($resourceType, $resourceId, $tenantId); + } + + public function filterTenantIdsForUser(array $tenantIds, int $userId): array + { + return $this->tenantScopeService()->filterTenantIdsForUser($tenantIds, $userId); + } + + public function mergeTenantIdsPreservingOutOfScope( + array $requestedTenantIds, + array $existingTenantIds, + array $allowedTenantIds + ): array { + return $this->tenantScopeService()->mergeTenantIdsPreservingOutOfScope( + $requestedTenantIds, + $existingTenantIds, + $allowedTenantIds + ); + } + + private function tenantScopeService(): TenantScopeService + { + if ($this->tenantScopeService instanceof TenantScopeService) { + return $this->tenantScopeService; + } + + return (new TenantServicesFactory())->createTenantScopeService(); + } +} diff --git a/lib/Service/Directory/DirectoryServicesFactory.php b/lib/Service/Directory/DirectoryServicesFactory.php new file mode 100644 index 0000000..915a1e0 --- /dev/null +++ b/lib/Service/Directory/DirectoryServicesFactory.php @@ -0,0 +1,99 @@ +tenantService ??= new TenantService( + $this->createTenantRepository(), + $this->createDepartmentRepository(), + $this->createDirectorySettingsGateway() + ); + } + + public function createDepartmentService(): DepartmentService + { + return $this->departmentService ??= new DepartmentService( + $this->createDepartmentRepository(), + $this->userServicesFactory(), + $this->createDirectorySettingsGateway(), + $this->createDirectoryScopeGateway() + ); + } + + public function createRoleService(): RoleService + { + return $this->roleService ??= new RoleService( + $this->createRoleRepository(), + $this->createDirectorySettingsGateway() + ); + } + + public function createTenantRepository(): TenantRepository + { + return $this->tenantRepository ??= new TenantRepository(); + } + + public function createDepartmentRepository(): DepartmentRepository + { + return $this->departmentRepository ??= new DepartmentRepository(); + } + + public function createRoleRepository(): RoleRepository + { + return $this->roleRepository ??= new RoleRepository(); + } + + public function createDirectorySettingsGateway(): DirectorySettingsGateway + { + return $this->directorySettingsGateway ??= new DirectorySettingsGateway( + $this->settingServicesFactory()->createSettingGateway() + ); + } + + public function createDirectoryScopeGateway(): DirectoryScopeGateway + { + return $this->directoryScopeGateway ??= new DirectoryScopeGateway( + $this->tenantServicesFactory()->createTenantScopeService() + ); + } + + private function userServicesFactory(): UserServicesFactory + { + return $this->userServicesFactory ??= new UserServicesFactory(); + } + + private function settingServicesFactory(): SettingServicesFactory + { + return $this->settingServicesFactory ??= new SettingServicesFactory(); + } + + private function tenantServicesFactory(): TenantServicesFactory + { + return $this->tenantServicesFactory ??= new TenantServicesFactory(); + } +} diff --git a/lib/Service/Directory/DirectorySettingsGateway.php b/lib/Service/Directory/DirectorySettingsGateway.php new file mode 100644 index 0000000..ce55c90 --- /dev/null +++ b/lib/Service/Directory/DirectorySettingsGateway.php @@ -0,0 +1,27 @@ +settingGateway->setDefaultTenantId($tenantId); + } + + public function setDefaultDepartmentId(?int $departmentId): void + { + $this->settingGateway->setDefaultDepartmentId($departmentId); + } + + public function setDefaultRoleId(?int $roleId): void + { + $this->settingGateway->setDefaultRoleId($roleId); + } +} diff --git a/lib/Service/Docs/DocsCatalogService.php b/lib/Service/Docs/DocsCatalogService.php index f60a52f..c094ac5 100644 --- a/lib/Service/Docs/DocsCatalogService.php +++ b/lib/Service/Docs/DocsCatalogService.php @@ -301,13 +301,13 @@ class DocsCatalogService $parsedHeadings = []; foreach ($headingBlocks as $headingBlock) { - $level = (int) ($headingBlock[1][0] ?? 0); + $level = (int) $headingBlock[1][0]; if (!in_array($level, [1, 2, 3], true)) { continue; } - $attrs = (string) ($headingBlock[2][0] ?? ''); - $html = (string) ($headingBlock[3][0] ?? ''); + $attrs = (string) $headingBlock[2][0]; + $html = (string) $headingBlock[3][0]; $text = self::normalizePlainText(strip_tags($html)); if ($text === '') { continue; @@ -315,7 +315,7 @@ class DocsCatalogService $id = ''; if (preg_match('/\bid=(["\'])([^"\']+)\1/i', $attrs, $idMatch)) { - $id = trim((string) ($idMatch[2] ?? '')); + $id = trim((string) $idMatch[2]); } if ($id === '') { @@ -325,8 +325,8 @@ class DocsCatalogService $id = self::nextUniqueAnchor($id, $seenAnchors); } - $fullHtml = (string) ($headingBlock[0][0] ?? ''); - $start = (int) ($headingBlock[0][1] ?? 0); + $fullHtml = (string) $headingBlock[0][0]; + $start = (int) $headingBlock[0][1]; $end = $start + strlen($fullHtml); $parsedHeadings[] = [ @@ -357,9 +357,9 @@ class DocsCatalogService } $heading = $parsedHeadings[$headingIndex++]; - $level = (string) ($match[1] ?? '2'); - $attrs = (string) ($match[2] ?? ''); - $html = (string) ($match[3] ?? ''); + $level = (string) $match[1]; + $attrs = (string) $match[2]; + $html = (string) $match[3]; if (preg_match('/\bid=(["\'])([^"\']+)\1/i', $attrs)) { return "{$html}"; @@ -517,21 +517,21 @@ class DocsCatalogService $docScore = self::titleScore($needle, $title); if ($docScore > 0) { $firstHeading = $headings[0]; - $docKey = $slug . '#' . (string) ($firstHeading['anchor'] ?? ''); + $docKey = $slug . '#' . (string) $firstHeading['anchor']; $resultsByKey[$docKey] = [ 'slug' => $slug, 'title' => $title, 'section' => '', - 'anchor' => (string) ($firstHeading['anchor'] ?? ''), - 'snippet' => self::buildSnippet((string) ($firstHeading['body'] ?? ''), $needle), + 'anchor' => (string) $firstHeading['anchor'], + 'snippet' => self::buildSnippet((string) $firstHeading['body'], $needle), 'score' => $docScore, ]; } foreach ($headings as $heading) { - $sectionText = (string) ($heading['text'] ?? ''); - $anchor = (string) ($heading['anchor'] ?? ''); - $body = (string) ($heading['body'] ?? ''); + $sectionText = (string) $heading['text']; + $anchor = (string) $heading['anchor']; + $body = (string) $heading['body']; $score = self::sectionScore($needle, $sectionText, $body); if ($score <= 0) { continue; diff --git a/lib/Service/Image/ImageUploadTrait.php b/lib/Service/Image/ImageUploadTrait.php index d9476d0..c1b06b3 100644 --- a/lib/Service/Image/ImageUploadTrait.php +++ b/lib/Service/Image/ImageUploadTrait.php @@ -131,7 +131,7 @@ trait ImageUploadTrait protected static function imageResizeAndFit(string $sourcePath, string $targetPath, int $width, int $height, string $ext): bool { - $mime = self::detectMime($sourcePath); + $mime = self::imageDetectMime($sourcePath); $src = self::imageCreateResource($sourcePath, $mime); if (!$src) { return false; diff --git a/lib/Service/Import/CsvReaderService.php b/lib/Service/Import/CsvReaderService.php index a230bf2..028a4f5 100644 --- a/lib/Service/Import/CsvReaderService.php +++ b/lib/Service/Import/CsvReaderService.php @@ -9,14 +9,14 @@ class CsvReaderService /** * @return array{ok:bool, delimiter?:string, headers?:array, rows_total?:int, code?:string} */ - public static function analyze(string $path, int $maxRows): array + public function analyze(string $path, int $maxRows): array { if (!is_file($path) || !is_readable($path)) { return ['ok' => false, 'code' => 'invalid_file_type']; } - $delimiter = self::detectDelimiter($path); - $headerMeta = self::readHeader($path, $delimiter); + $delimiter = $this->detectDelimiter($path); + $headerMeta = $this->readHeader($path, $delimiter); if (!$headerMeta['ok']) { $code = isset($headerMeta['code']) ? (string) $headerMeta['code'] : 'invalid_csv_header'; return ['ok' => false, 'code' => $code]; @@ -28,7 +28,7 @@ class CsvReaderService return ['ok' => false, 'code' => 'invalid_csv_header']; } - $rowsTotal = self::countRows($path, $delimiter, $headers, $headerLine); + $rowsTotal = $this->countRows($path, $delimiter, $headers, $headerLine); if ($rowsTotal <= 0) { return ['ok' => false, 'code' => 'empty_csv']; } @@ -47,9 +47,9 @@ class CsvReaderService /** * @param array $headers */ - public static function streamRows(string $path, string $delimiter, array $headers, callable $callback): void + public function streamRows(string $path, string $delimiter, array $headers, callable $callback): void { - $headerMeta = self::readHeader($path, $delimiter); + $headerMeta = $this->readHeader($path, $delimiter); if (!$headerMeta['ok']) { return; } @@ -68,15 +68,15 @@ class CsvReaderService continue; } - $row = self::normalizeRowColumns($row, count($headers)); - if (self::isEmptyRow($row)) { + $row = $this->normalizeRowColumns($row, count($headers)); + if ($this->isEmptyRow($row)) { continue; } $rowIndex++; $assoc = []; foreach ($headers as $index => $header) { - $assoc[$header] = self::cleanCell($row[$index] ?? ''); + $assoc[$header] = $this->cleanCell($row[$index] ?? ''); } $callback($assoc, $lineNumber, $rowIndex); } @@ -84,7 +84,7 @@ class CsvReaderService fclose($handle); } - private static function detectDelimiter(string $path): string + private function detectDelimiter(string $path): string { $sample = ''; $handle = @fopen($path, 'rb'); @@ -115,7 +115,7 @@ class CsvReaderService /** * @return array{ok:bool, headers?:array, header_line?:int, code?:string} */ - private static function readHeader(string $path, string $delimiter): array + private function readHeader(string $path, string $delimiter): array { $handle = @fopen($path, 'rb'); if (!$handle) { @@ -128,16 +128,16 @@ class CsvReaderService if (!is_array($row)) { continue; } - $row = self::normalizeRowColumns($row, count($row)); - if (self::isEmptyRow($row)) { + $row = $this->normalizeRowColumns($row, count($row)); + if ($this->isEmptyRow($row)) { continue; } $headers = []; foreach ($row as $index => $cell) { - $value = self::cleanCell((string) $cell); + $value = $this->cleanCell((string) $cell); if ($index === 0) { - $value = self::stripBom($value); + $value = $this->stripBom($value); } $headers[] = trim($value); } @@ -154,7 +154,7 @@ class CsvReaderService } } - $normalizedKeys = array_map([self::class, 'lower'], $headers); + $normalizedKeys = array_map([$this, 'lower'], $headers); if (count(array_unique($normalizedKeys)) !== count($normalizedKeys)) { return ['ok' => false, 'code' => 'invalid_csv_header']; } @@ -170,11 +170,11 @@ class CsvReaderService * @param array $row * @return array */ - private static function normalizeRowColumns(array $row, int $size): array + private function normalizeRowColumns(array $row, int $size): array { $normalized = []; foreach ($row as $cell) { - $normalized[] = self::cleanCell((string) $cell); + $normalized[] = $this->cleanCell((string) $cell); } $current = count($normalized); @@ -190,7 +190,7 @@ class CsvReaderService /** * @param array $headers */ - private static function countRows(string $path, string $delimiter, array $headers, int $headerLine): int + private function countRows(string $path, string $delimiter, array $headers, int $headerLine): int { $count = 0; $handle = @fopen($path, 'rb'); @@ -207,8 +207,8 @@ class CsvReaderService if (!is_array($row)) { continue; } - $row = self::normalizeRowColumns($row, count($headers)); - if (self::isEmptyRow($row)) { + $row = $this->normalizeRowColumns($row, count($headers)); + if ($this->isEmptyRow($row)) { continue; } $count++; @@ -218,7 +218,7 @@ class CsvReaderService return $count; } - private static function isEmptyRow(array $row): bool + private function isEmptyRow(array $row): bool { foreach ($row as $cell) { if (trim((string) $cell) !== '') { @@ -228,7 +228,7 @@ class CsvReaderService return true; } - private static function stripBom(string $value): string + private function stripBom(string $value): string { if (str_starts_with($value, "\xEF\xBB\xBF")) { return substr($value, 3); @@ -236,14 +236,14 @@ class CsvReaderService return $value; } - private static function cleanCell(string $value): string + private function cleanCell(string $value): string { $value = str_replace("\0", '', $value); - $value = self::stripBom($value); + $value = $this->stripBom($value); return trim($value); } - private static function lower(string $value): string + private function lower(string $value): string { if (function_exists('mb_strtolower')) { return mb_strtolower($value, 'UTF-8'); diff --git a/lib/Service/Import/ImportService.php b/lib/Service/Import/ImportService.php index bf06868..74ac641 100644 --- a/lib/Service/Import/ImportService.php +++ b/lib/Service/Import/ImportService.php @@ -3,16 +3,14 @@ namespace MintyPHP\Service\Import; use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Access\PermissionGateway; use MintyPHP\Service\Audit\ImportAuditService; -use MintyPHP\Service\Import\Profile\DepartmentImportProfile; use MintyPHP\Service\Import\Profile\ImportProfileInterface; -use MintyPHP\Service\Import\Profile\UserImportProfile; -use MintyPHP\Service\Settings\SettingService; -use MintyPHP\Service\Tenant\TenantScopeService; +use MintyPHP\Service\Settings\SettingGateway; +use MintyPHP\Service\User\UserScopeGateway; class ImportService { - private const SESSION_KEY = 'admin_imports_v1'; private const MAX_ROWS = 20000; private const MAX_ERROR_ROWS = 500; private const PROFILE_PERMISSION_MAP = [ @@ -21,42 +19,44 @@ class ImportService ]; /** - * @return array + * @param array $profiles */ - public static function profiles(): array - { - static $profiles = null; - if (is_array($profiles)) { - return $profiles; - } - - $users = new UserImportProfile(); - $departments = new DepartmentImportProfile(); - $profiles = [ - $users->key() => $users, - $departments->key() => $departments, - ]; - - return $profiles; + public function __construct( + private readonly CsvReaderService $csvReaderService, + private readonly ImportTempFileService $importTempFileService, + private readonly ImportAuditService $importAuditService, + private readonly ImportStateStoreService $importStateStoreService, + private readonly array $profiles, + private readonly PermissionGateway $permissionGateway, + private readonly SettingGateway $settingGateway, + private readonly UserScopeGateway $userScopeGateway + ) { } - public static function profile(string $type): ?ImportProfileInterface + /** + * @return array + */ + public function profiles(): array + { + return $this->profiles; + } + + public function profile(string $type): ?ImportProfileInterface { $type = trim($type); if ($type === '') { return null; } - $profiles = self::profiles(); - return $profiles[$type] ?? null; + return $this->profiles[$type] ?? null; } /** * @return array */ - public static function listProfileOptions(): array + public function listProfileOptions(): array { $options = []; - foreach (self::profiles() as $profile) { + foreach ($this->profiles() as $profile) { $options[] = [ 'key' => $profile->key(), 'label' => $profile->label(), @@ -65,7 +65,7 @@ class ImportService return $options; } - public static function requiredPermissionForType(string $type): string + public function requiredPermissionForType(string $type): string { $type = trim($type); if ($type === '') { @@ -78,23 +78,23 @@ class ImportService * @param array $upload * @return array */ - public static function analyzeUpload(array $upload, string $type, int $currentUserId): array + public function analyzeUpload(array $upload, string $type, int $currentUserId): array { - $profile = self::profile($type); + $profile = $this->profile($type); if (!$profile) { return ['ok' => false, 'code' => 'invalid_file_type']; } - $stored = ImportTempFileService::storeUploadedFile($upload); + $stored = $this->importTempFileService->storeUploadedFile($upload); if (!$stored['ok']) { $code = isset($stored['code']) ? (string) $stored['code'] : 'upload_missing'; return ['ok' => false, 'code' => $code]; } $path = (string) ($stored['path'] ?? ''); - $analysis = CsvReaderService::analyze($path, self::MAX_ROWS); + $analysis = $this->csvReaderService->analyze($path, self::MAX_ROWS); if (!$analysis['ok']) { - ImportTempFileService::delete($path); + $this->importTempFileService->delete($path); $code = isset($analysis['code']) ? (string) $analysis['code'] : 'invalid_csv_header'; return ['ok' => false, 'code' => $code]; } @@ -103,8 +103,8 @@ class ImportService $delimiter = (string) ($analysis['delimiter'] ?? ','); $rowsTotal = (int) ($analysis['rows_total'] ?? 0); - $token = self::newToken(); - self::setState($token, [ + $token = $this->newToken(); + $this->importStateStoreService->setState($token, [ 'token' => $token, 'type' => $profile->key(), 'path' => $path, @@ -125,7 +125,7 @@ class ImportService 'delimiter' => $delimiter, 'allowed_targets' => $profile->allowedTargets(), 'required_targets' => $profile->requiredTargets(), - 'auto_mapping' => self::buildAutoMapping($headers, $profile), + 'auto_mapping' => $this->buildAutoMapping($headers, $profile), ]; } @@ -133,9 +133,9 @@ class ImportService * @param array $mappingInput * @return array */ - public static function preview(string $token, array $mappingInput, int $currentUserId): array + public function preview(string $token, array $mappingInput, int $currentUserId): array { - $state = self::state($token); + $state = $this->state($token); if (!$state) { return ['ok' => false, 'code' => 'invalid_file_type']; } @@ -143,12 +143,12 @@ class ImportService return ['ok' => false, 'code' => 'invalid_file_type']; } - $profile = self::profile((string) ($state['type'] ?? '')); + $profile = $this->profile((string) ($state['type'] ?? '')); if (!$profile) { return ['ok' => false, 'code' => 'invalid_file_type']; } - $mappingResult = self::normalizeMapping($mappingInput, (array) ($state['headers'] ?? []), $profile); + $mappingResult = $this->normalizeMapping($mappingInput, (array) ($state['headers'] ?? []), $profile); if (!($mappingResult['ok'] ?? false)) { return ['ok' => false, 'code' => (string) ($mappingResult['code'] ?? 'mapping_required_missing')]; } @@ -156,22 +156,22 @@ class ImportService $mappedTargets = $mappingResult['mapped_targets'] ?? []; if ( $profile->requiresAssignmentPermission() - && self::usesAssignmentTargets($mappedTargets) - && !self::canImportAssignments($currentUserId) + && $this->usesAssignmentTargets($mappedTargets) + && !$this->canImportAssignments($currentUserId) ) { return ['ok' => false, 'code' => 'assignment_permission_required']; } - return self::process($state, $mappingResult['mapping'], $profile, $currentUserId, false); + return $this->process($state, $mappingResult['mapping'], $profile, $currentUserId, false); } /** * @param array $mappingInput * @return array */ - public static function commit(string $token, array $mappingInput, int $currentUserId): array + public function commit(string $token, array $mappingInput, int $currentUserId): array { - $state = self::state($token); + $state = $this->state($token); if (!$state) { return ['ok' => false, 'code' => 'invalid_file_type']; } @@ -179,12 +179,12 @@ class ImportService return ['ok' => false, 'code' => 'invalid_file_type']; } - $profile = self::profile((string) ($state['type'] ?? '')); + $profile = $this->profile((string) ($state['type'] ?? '')); if (!$profile) { return ['ok' => false, 'code' => 'invalid_file_type']; } - $mappingResult = self::normalizeMapping($mappingInput, (array) ($state['headers'] ?? []), $profile); + $mappingResult = $this->normalizeMapping($mappingInput, (array) ($state['headers'] ?? []), $profile); if (!($mappingResult['ok'] ?? false)) { return ['ok' => false, 'code' => (string) ($mappingResult['code'] ?? 'mapping_required_missing')]; } @@ -192,13 +192,13 @@ class ImportService $mappedTargets = $mappingResult['mapped_targets'] ?? []; if ( $profile->requiresAssignmentPermission() - && self::usesAssignmentTargets($mappedTargets) - && !self::canImportAssignments($currentUserId) + && $this->usesAssignmentTargets($mappedTargets) + && !$this->canImportAssignments($currentUserId) ) { return ['ok' => false, 'code' => 'assignment_permission_required']; } - $auditRunId = ImportAuditService::startRun( + $auditRunId = $this->importAuditService->startRun( (string) ($state['type'] ?? $profile->key()), $mappingResult['mapped_targets'] ?? [], isset($state['source_filename']) ? (string) $state['source_filename'] : null, @@ -206,51 +206,29 @@ class ImportService isset($_SESSION['current_tenant']['id']) ? (int) $_SESSION['current_tenant']['id'] : null ); - $result = null; + $result = ['ok' => false, 'code' => 'unexpected_error', 'processed' => 0, 'created' => 0, 'skipped' => 0, 'failed' => 1]; $forcedAuditStatus = null; try { - $result = self::process($state, $mappingResult['mapping'], $profile, $currentUserId, true); + $result = $this->process($state, $mappingResult['mapping'], $profile, $currentUserId, true); } catch (\Throwable $exception) { $forcedAuditStatus = 'failed'; $result = ['ok' => false, 'code' => 'unexpected_error', 'processed' => 0, 'created' => 0, 'skipped' => 0, 'failed' => 1]; } finally { - ImportAuditService::finishRun($auditRunId, is_array($result) ? $result : [], $forcedAuditStatus); + $this->importAuditService->finishRun($auditRunId, $result, $forcedAuditStatus); } - ImportTempFileService::delete((string) ($state['path'] ?? '')); - self::clearState($token); + $this->importTempFileService->delete((string) ($state['path'] ?? '')); + $this->importStateStoreService->clearState($token); - return is_array($result) ? $result : ['ok' => false, 'code' => 'unexpected_error']; + return $result; } /** * @return array|null */ - public static function state(string $token): ?array + public function state(string $token): ?array { - $token = trim($token); - if ($token === '') { - return null; - } - - $all = $_SESSION[self::SESSION_KEY] ?? []; - if (!is_array($all)) { - return null; - } - - $state = $all[$token] ?? null; - if (!is_array($state)) { - return null; - } - - $createdAt = (int) ($state['created_at'] ?? 0); - if ($createdAt > 0 && (time() - $createdAt) > 7200) { - ImportTempFileService::delete((string) ($state['path'] ?? '')); - self::clearState($token); - return null; - } - - return $state; + return $this->importStateStoreService->getState($token); } /** @@ -258,7 +236,7 @@ class ImportService * @param array $mapping * @return array */ - private static function process( + private function process( array $state, array $mapping, ImportProfileInterface $profile, @@ -272,7 +250,7 @@ class ImportService return ['ok' => false, 'code' => 'invalid_csv_header']; } - $context = self::buildContext($currentUserId); + $context = $this->buildContext($currentUserId); $seenRowKeys = []; $processed = 0; $created = 0; @@ -282,7 +260,7 @@ class ImportService $errors = []; $errorCounts = []; - CsvReaderService::streamRows( + $this->csvReaderService->streamRows( $path, $delimiter, array_values($headers), @@ -304,13 +282,13 @@ class ImportService $mapped = []; $identifier = ''; try { - $mapped = self::mapRow($rowAssoc, $mapping); + $mapped = $this->mapRow($rowAssoc, $mapping); $validation = $profile->validateMappedRow($mapped, $context); if (!$validation['ok']) { $failed++; foreach ($validation['errors'] as $error) { - self::incrementErrorCount($errorCounts, (string) ($error['code'] ?? 'unexpected_error')); - self::pushError( + $this->incrementErrorCount($errorCounts, (string) $error['code']); + $this->pushError( $errors, $lineNumber, $profile->rowIdentifier($mapped), @@ -327,8 +305,8 @@ class ImportService if ($duplicateKey !== null && $duplicateKey !== '') { if (isset($seenRowKeys[$duplicateKey])) { $skipped++; - self::incrementErrorCount($errorCounts, 'duplicate_in_file'); - self::pushError($errors, $lineNumber, $identifier, 'duplicate_in_file', null); + $this->incrementErrorCount($errorCounts, 'duplicate_in_file'); + $this->pushError($errors, $lineNumber, $identifier, 'duplicate_in_file', null); return; } $seenRowKeys[$duplicateKey] = true; @@ -350,18 +328,18 @@ class ImportService if ($status === 'skipped') { $skipped++; $errorCode = $code !== '' ? $code : 'email_exists'; - self::incrementErrorCount($errorCounts, $errorCode); - self::pushError($errors, $lineNumber, $identifier, $errorCode, $message); + $this->incrementErrorCount($errorCounts, $errorCode); + $this->pushError($errors, $lineNumber, $identifier, $errorCode, $message); return; } $failed++; $errorCode = $code !== '' ? $code : 'unexpected_error'; - self::incrementErrorCount($errorCounts, $errorCode); - self::pushError($errors, $lineNumber, $identifier, $errorCode, $message); + $this->incrementErrorCount($errorCounts, $errorCode); + $this->pushError($errors, $lineNumber, $identifier, $errorCode, $message); } catch (\Throwable $exception) { $failed++; - self::incrementErrorCount($errorCounts, 'unexpected_error'); + $this->incrementErrorCount($errorCounts, 'unexpected_error'); if ($identifier === '' && $mapped) { try { $identifier = $profile->rowIdentifier($mapped); @@ -369,7 +347,7 @@ class ImportService $identifier = ''; } } - self::pushError($errors, $lineNumber, $identifier, 'unexpected_error', null); + $this->pushError($errors, $lineNumber, $identifier, 'unexpected_error', null); } } ); @@ -401,7 +379,7 @@ class ImportService * @param array $headers * @return array */ - private static function normalizeMapping(array $mappingInput, array $headers, ImportProfileInterface $profile): array + private function normalizeMapping(array $mappingInput, array $headers, ImportProfileInterface $profile): array { $mappingRaw = $mappingInput['mapping'] ?? $mappingInput; if (!is_array($mappingRaw)) { @@ -445,7 +423,7 @@ class ImportService * @param array $rowAssoc * @return array */ - private static function mapRow(array $rowAssoc, array $mapping): array + private function mapRow(array $rowAssoc, array $mapping): array { $mapped = []; foreach ($mapping as $header => $target) { @@ -461,22 +439,22 @@ class ImportService * @param array $headers * @return array */ - private static function buildAutoMapping(array $headers, ImportProfileInterface $profile): array + private function buildAutoMapping(array $headers, ImportProfileInterface $profile): array { $mapping = []; $aliases = $profile->autoMapAliases(); $normalizedAliasMap = []; foreach ($aliases as $target => $targetAliases) { - $normalizedAliasMap[self::normalizeHeader($target)] = $target; + $normalizedAliasMap[$this->normalizeHeader($target)] = $target; foreach ($targetAliases as $alias) { - $normalizedAliasMap[self::normalizeHeader($alias)] = $target; + $normalizedAliasMap[$this->normalizeHeader($alias)] = $target; } } $assignedTargets = []; foreach ($headers as $header) { - $normalized = self::normalizeHeader($header); + $normalized = $this->normalizeHeader($header); if ($normalized === '') { continue; } @@ -494,7 +472,7 @@ class ImportService /** * @param array $mappedTargets */ - private static function usesAssignmentTargets(array $mappedTargets): bool + private function usesAssignmentTargets(array $mappedTargets): bool { foreach ($mappedTargets as $target) { if (in_array($target, ['tenant', 'role', 'department'], true)) { @@ -504,29 +482,29 @@ class ImportService return false; } - private static function canImportAssignments(int $userId): bool + private function canImportAssignments(int $userId): bool { - return PermissionService::userHas($userId, PermissionService::USERS_IMPORT_ASSIGNMENTS); + return $this->permissionGateway->userHas($userId, PermissionService::USERS_IMPORT_ASSIGNMENTS); } /** * @return array */ - private static function buildContext(int $currentUserId): array + private function buildContext(int $currentUserId): array { - $isGlobalAdmin = TenantScopeService::hasGlobalAccess($currentUserId); + $isGlobalAdmin = $this->userScopeGateway->hasGlobalAccess($currentUserId); return [ 'current_user_id' => $currentUserId, 'is_global_admin' => $isGlobalAdmin, - 'allowed_tenant_ids' => $isGlobalAdmin ? [] : TenantScopeService::getUserTenantIds($currentUserId), - 'default_tenant_id' => SettingService::getDefaultTenantId(), - 'default_role_id' => SettingService::getDefaultRoleId(), - 'default_department_id' => SettingService::getDefaultDepartmentId(), - 'can_import_assignments' => self::canImportAssignments($currentUserId), + 'allowed_tenant_ids' => $isGlobalAdmin ? [] : $this->userScopeGateway->getUserTenantIds($currentUserId), + 'default_tenant_id' => $this->settingGateway->getDefaultTenantId(), + 'default_role_id' => $this->settingGateway->getDefaultRoleId(), + 'default_department_id' => $this->settingGateway->getDefaultDepartmentId(), + 'can_import_assignments' => $this->canImportAssignments($currentUserId), ]; } - private static function newToken(): string + private function newToken(): string { try { return bin2hex(random_bytes(24)); @@ -535,33 +513,14 @@ class ImportService } } - /** - * @param array $state - */ - private static function setState(string $token, array $state): void - { - if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) { - $_SESSION[self::SESSION_KEY] = []; - } - $_SESSION[self::SESSION_KEY][$token] = $state; - } - - private static function clearState(string $token): void - { - if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) { - return; - } - unset($_SESSION[self::SESSION_KEY][$token]); - } - - private static function normalizeHeader(string $value): string + private function normalizeHeader(string $value): string { $value = trim($value); if ($value === '') { return ''; } - $value = self::lower($value); + $value = $this->lower($value); $value = str_replace(['-', '.', ' '], '_', $value); $value = preg_replace('/[^a-z0-9_]/', '', $value) ?? ''; $value = preg_replace('/_+/', '_', $value) ?? ''; @@ -571,7 +530,7 @@ class ImportService /** * @param array $errors */ - private static function pushError(array &$errors, int $lineNumber, string $identifier, string $code, ?string $message): void + private function pushError(array &$errors, int $lineNumber, string $identifier, string $code, ?string $message): void { if (count($errors) >= self::MAX_ERROR_ROWS) { return; @@ -580,14 +539,14 @@ class ImportService 'row_number' => $lineNumber, 'identifier' => $identifier, 'code' => $code, - 'message' => $message !== null && $message !== '' ? $message : self::defaultMessageForCode($code), + 'message' => $message !== null && $message !== '' ? $message : $this->defaultMessageForCode($code), ]; } /** * @param array $errorCounts */ - private static function incrementErrorCount(array &$errorCounts, string $code): void + private function incrementErrorCount(array &$errorCounts, string $code): void { $code = trim($code); if ($code === '') { @@ -596,47 +555,59 @@ class ImportService $errorCounts[$code] = (int) ($errorCounts[$code] ?? 0) + 1; } - private static function defaultMessageForCode(string $code): string + private function defaultMessageForCode(string $code): string { $map = [ - 'upload_missing' => t('No upload file provided'), - 'upload_too_large' => t('Upload exceeds the maximum size'), - 'invalid_file_type' => t('Invalid file type'), - 'invalid_csv_header' => t('CSV header is invalid'), - 'empty_csv' => t('CSV file has no data rows'), - 'max_rows_exceeded' => t('CSV row limit exceeded'), - 'mapping_required_missing' => t('Required mapping is missing'), - 'assignment_permission_required' => t('Assignment import permission is required'), - 'invalid_email' => t('Email is invalid'), - 'duplicate_in_file' => t('Duplicate entry in CSV file'), - 'email_exists' => t('Email already exists'), - 'invalid_locale' => t('Locale is invalid'), - 'invalid_active' => t('Active value is invalid'), - 'invalid_hire_date' => t('Hire date must be YYYY-MM-DD'), - 'invalid_tenant_uuid' => t('Tenant must be a valid UUID'), - 'tenant_not_found' => t('Tenant is missing, inactive or invalid'), - 'role_not_found' => t('Role is missing, inactive or invalid'), - 'department_not_found' => t('Department is missing, inactive or invalid'), - 'department_tenant_mismatch' => t('Department does not belong to tenant'), - 'assignment_out_of_scope' => t('Tenant assignment is out of your scope'), - 'code_exists' => t('Code already exists'), - 'department_exists' => t('Department already exists for this tenant'), - 'create_failed' => t('Entry could not be created'), - 'unexpected_error' => t('Unexpected error during import'), + 'upload_missing' => $this->translate('No upload file provided'), + 'upload_too_large' => $this->translate('Upload exceeds the maximum size'), + 'invalid_file_type' => $this->translate('Invalid file type'), + 'invalid_csv_header' => $this->translate('CSV header is invalid'), + 'empty_csv' => $this->translate('CSV file has no data rows'), + 'max_rows_exceeded' => $this->translate('CSV row limit exceeded'), + 'mapping_required_missing' => $this->translate('Required mapping is missing'), + 'assignment_permission_required' => $this->translate('Assignment import permission is required'), + 'invalid_email' => $this->translate('Email is invalid'), + 'duplicate_in_file' => $this->translate('Duplicate entry in CSV file'), + 'email_exists' => $this->translate('Email already exists'), + 'invalid_locale' => $this->translate('Locale is invalid'), + 'invalid_active' => $this->translate('Active value is invalid'), + 'invalid_hire_date' => $this->translate('Hire date must be YYYY-MM-DD'), + 'invalid_tenant_uuid' => $this->translate('Tenant must be a valid UUID'), + 'tenant_not_found' => $this->translate('Tenant is missing, inactive or invalid'), + 'role_not_found' => $this->translate('Role is missing, inactive or invalid'), + 'department_not_found' => $this->translate('Department is missing, inactive or invalid'), + 'department_tenant_mismatch' => $this->translate('Department does not belong to tenant'), + 'assignment_out_of_scope' => $this->translate('Tenant assignment is out of your scope'), + 'code_exists' => $this->translate('Code already exists'), + 'department_exists' => $this->translate('Department already exists for this tenant'), + 'create_failed' => $this->translate('Entry could not be created'), + 'unexpected_error' => $this->translate('Unexpected error during import'), ]; - return $map[$code] ?? t('Unexpected error during import'); + return $map[$code] ?? $this->translate('Unexpected error during import'); } - public static function messageForCode(string $code): string + public function messageForCode(string $code): string { - return self::defaultMessageForCode(trim($code)); + return $this->defaultMessageForCode(trim($code)); } - private static function lower(string $value): string + private function lower(string $value): string { if (function_exists('mb_strtolower')) { return mb_strtolower($value, 'UTF-8'); } return strtolower($value); } + + private function translate(string $text, mixed ...$args): string + { + if (function_exists('t')) { + return t($text, ...$args); + } + if ($args === []) { + return $text; + } + return vsprintf($text, array_map(static fn ($arg) => (string) $arg, $args)); + } + } diff --git a/lib/Service/Import/ImportServicesFactory.php b/lib/Service/Import/ImportServicesFactory.php new file mode 100644 index 0000000..48a2a1e --- /dev/null +++ b/lib/Service/Import/ImportServicesFactory.php @@ -0,0 +1,106 @@ +importService !== null) { + return $this->importService; + } + + $profiles = [ + 'users' => new UserImportProfile(new UserImportGateway( + $this->getUserServicesFactory()->createUserReadRepository(), + $this->getUserServicesFactory()->createUserAccountService() + )), + 'departments' => new DepartmentImportProfile(new DepartmentImportGateway()), + ]; + + return $this->importService = new ImportService( + $this->getCsvReaderService(), + $this->getImportTempFileService(), + $this->createImportAuditService(), + $this->createImportStateStoreService(), + $profiles, + $this->createPermissionGateway(), + $this->createSettingGateway(), + $this->getUserServicesFactory()->createUserScopeGateway() + ); + } + + public function createImportAuditService(): ImportAuditService + { + return $this->importAuditService ??= new ImportAuditService($this->getImportAuditRunRepository()); + } + + public function createImportStateStoreService(): ImportStateStoreService + { + return $this->importStateStoreService ??= new ImportStateStoreService($this->getImportTempFileService()); + } + + private function getCsvReaderService(): CsvReaderService + { + return $this->csvReaderService ??= new CsvReaderService(); + } + + private function getImportTempFileService(): ImportTempFileService + { + return $this->importTempFileService ??= new ImportTempFileService(); + } + + private function getImportAuditRunRepository(): ImportAuditRunRepository + { + return $this->importAuditRunRepository ??= new ImportAuditRunRepository(); + } + + private function getUserServicesFactory(): UserServicesFactory + { + return $this->userServicesFactory ??= new UserServicesFactory(); + } + + private function createPermissionGateway(): PermissionGateway + { + return $this->permissionGateway ??= $this->accessServicesFactory()->createPermissionGateway(); + } + + private function accessServicesFactory(): AccessServicesFactory + { + return $this->accessServicesFactory ??= new AccessServicesFactory(); + } + + private function createSettingGateway(): SettingGateway + { + return $this->settingGateway ??= $this->settingServicesFactory()->createSettingGateway(); + } + + private function settingServicesFactory(): SettingServicesFactory + { + return $this->settingServicesFactory ??= new SettingServicesFactory(); + } +} diff --git a/lib/Service/Import/ImportStateStoreService.php b/lib/Service/Import/ImportStateStoreService.php new file mode 100644 index 0000000..63f4081 --- /dev/null +++ b/lib/Service/Import/ImportStateStoreService.php @@ -0,0 +1,63 @@ + $state + */ + public function setState(string $token, array $state): void + { + if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) { + $_SESSION[self::SESSION_KEY] = []; + } + $_SESSION[self::SESSION_KEY][$token] = $state; + } + + /** + * @return array|null + */ + public function getState(string $token): ?array + { + $token = trim($token); + if ($token === '') { + return null; + } + + $all = $_SESSION[self::SESSION_KEY] ?? []; + if (!is_array($all)) { + return null; + } + + $state = $all[$token] ?? null; + if (!is_array($state)) { + return null; + } + + $createdAt = (int) ($state['created_at'] ?? 0); + if ($createdAt > 0 && (time() - $createdAt) > self::STATE_TTL_SECONDS) { + $this->importTempFileService->delete((string) ($state['path'] ?? '')); + $this->clearState($token); + return null; + } + + return $state; + } + + public function clearState(string $token): void + { + if (!isset($_SESSION[self::SESSION_KEY]) || !is_array($_SESSION[self::SESSION_KEY])) { + return; + } + unset($_SESSION[self::SESSION_KEY][$token]); + } +} + diff --git a/lib/Service/Import/ImportTempFileService.php b/lib/Service/Import/ImportTempFileService.php index 91164bd..2b6dc61 100644 --- a/lib/Service/Import/ImportTempFileService.php +++ b/lib/Service/Import/ImportTempFileService.php @@ -12,7 +12,7 @@ class ImportTempFileService * @param array $upload * @return array{ok:bool, path?:string, code?:string} */ - public static function storeUploadedFile(array $upload): array + public function storeUploadedFile(array $upload): array { $tmpName = (string) ($upload['tmp_name'] ?? ''); $name = (string) ($upload['name'] ?? ''); @@ -31,16 +31,16 @@ class ImportTempFileService if ($size > self::MAX_UPLOAD_BYTES) { return ['ok' => false, 'code' => 'upload_too_large']; } - if (!self::isAllowedExtension($name)) { + if (!$this->isAllowedExtension($name)) { return ['ok' => false, 'code' => 'invalid_file_type']; } - $dir = self::tmpDir(); + $dir = $this->tmpDir(); if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { return ['ok' => false, 'code' => 'unexpected_error']; } - self::cleanupExpired(); + $this->cleanupExpired(); try { $target = $dir . '/import_' . bin2hex(random_bytes(16)) . '.csv'; @@ -65,13 +65,13 @@ class ImportTempFileService return ['ok' => true, 'path' => $target]; } - public static function delete(string $path): void + public function delete(string $path): void { $path = trim($path); if ($path === '') { return; } - if (!self::isInTmpDir($path)) { + if (!$this->isInTmpDir($path)) { return; } if (is_file($path)) { @@ -79,9 +79,9 @@ class ImportTempFileService } } - public static function cleanupExpired(): void + public function cleanupExpired(): void { - $dir = self::tmpDir(); + $dir = $this->tmpDir(); if (!is_dir($dir)) { return; } @@ -103,7 +103,7 @@ class ImportTempFileService } } - public static function storageBase(): string + public function storageBase(): string { if (defined('APP_STORAGE_PATH') && APP_STORAGE_PATH) { return rtrim((string) APP_STORAGE_PATH, '/'); @@ -111,20 +111,20 @@ class ImportTempFileService return rtrim(dirname(__DIR__, 3) . '/storage', '/'); } - public static function tmpDir(): string + public function tmpDir(): string { - return self::storageBase() . self::TMP_SUBDIR; + return $this->storageBase() . self::TMP_SUBDIR; } - private static function isAllowedExtension(string $name): bool + private function isAllowedExtension(string $name): bool { $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); return in_array($extension, ['csv', 'txt'], true); } - private static function isInTmpDir(string $path): bool + private function isInTmpDir(string $path): bool { - $tmpDir = self::tmpDir(); + $tmpDir = $this->tmpDir(); $realTmpDir = realpath($tmpDir); $realPath = realpath($path); if ($realTmpDir === false || $realPath === false) { diff --git a/lib/Service/Import/Profile/DepartmentImportGateway.php b/lib/Service/Import/Profile/DepartmentImportGateway.php new file mode 100644 index 0000000..4c48303 --- /dev/null +++ b/lib/Service/Import/Profile/DepartmentImportGateway.php @@ -0,0 +1,61 @@ +tenantRepository()->findByUuid($uuid); + } + + public function findTenantById(int $id): ?array + { + return $this->tenantRepository()->find($id); + } + + public function existsDepartmentCode(string $code): bool + { + return $this->departmentRepository()->existsByCode($code); + } + + public function existsDepartmentByTenantAndDescription(int $tenantId, string $description): bool + { + return $this->departmentRepository()->existsByTenantAndDescription($tenantId, $description); + } + + /** + * @param array $input + * @return array + */ + public function createDepartmentFromAdmin(array $input, int $currentUserId): array + { + return $this->departmentService()->createFromAdmin($input, $currentUserId); + } + + private function departmentRepository(): DepartmentRepository + { + return $this->departmentRepository ?? new DepartmentRepository(); + } + + private function tenantRepository(): TenantRepository + { + return $this->tenantRepository ?? new TenantRepository(); + } + + private function departmentService(): DepartmentService + { + return $this->departmentService ?? new DepartmentService(); + } +} diff --git a/lib/Service/Import/Profile/DepartmentImportProfile.php b/lib/Service/Import/Profile/DepartmentImportProfile.php index 3a7f3ae..ee1c008 100644 --- a/lib/Service/Import/Profile/DepartmentImportProfile.php +++ b/lib/Service/Import/Profile/DepartmentImportProfile.php @@ -2,12 +2,12 @@ namespace MintyPHP\Service\Import\Profile; -use MintyPHP\Repository\Org\DepartmentRepository; -use MintyPHP\Repository\Tenant\TenantRepository; -use MintyPHP\Service\Org\DepartmentService; - class DepartmentImportProfile implements ImportProfileInterface { + public function __construct(private readonly DepartmentImportGateway $gateway) + { + } + public function key(): string { return 'departments'; @@ -81,7 +81,7 @@ class DepartmentImportProfile implements ImportProfileInterface if (!$this->isUuid($tenantRaw)) { $errors[] = ['code' => 'invalid_tenant_uuid']; } else { - $tenant = TenantRepository::findByUuid($tenantRaw); + $tenant = $this->gateway->findTenantByUuid($tenantRaw); if (!$tenant || (string) ($tenant['status'] ?? '') !== 'active') { $errors[] = ['code' => 'tenant_not_found']; } @@ -91,7 +91,7 @@ class DepartmentImportProfile implements ImportProfileInterface if (!$tenant) { $defaultTenantId = (int) ($context['default_tenant_id'] ?? 0); if ($defaultTenantId > 0) { - $defaultTenant = TenantRepository::find($defaultTenantId); + $defaultTenant = $this->gateway->findTenantById($defaultTenantId); if ($defaultTenant && (string) ($defaultTenant['status'] ?? '') === 'active') { $tenant = $defaultTenant; } @@ -139,7 +139,7 @@ class DepartmentImportProfile implements ImportProfileInterface return ['status' => 'skipped', 'code' => 'department_exists']; } - $result = DepartmentService::createFromAdmin([ + $result = $this->gateway->createDepartmentFromAdmin([ 'description' => (string) ($normalized['description'] ?? ''), 'tenant_id' => (int) ($normalized['tenant_id'] ?? 0), 'code' => (string) ($normalized['code'] ?? ''), @@ -190,7 +190,7 @@ class DepartmentImportProfile implements ImportProfileInterface if ($code === '') { return false; } - return DepartmentRepository::existsByCode($code); + return $this->gateway->existsDepartmentCode($code); } private function departmentExists(array $normalized): bool @@ -200,7 +200,7 @@ class DepartmentImportProfile implements ImportProfileInterface if ($tenantId <= 0 || $description === '') { return false; } - return DepartmentRepository::existsByTenantAndDescription($tenantId, $description); + return $this->gateway->existsDepartmentByTenantAndDescription($tenantId, $description); } private function parseActive(string $value): ?int diff --git a/lib/Service/Import/Profile/UserImportGateway.php b/lib/Service/Import/Profile/UserImportGateway.php new file mode 100644 index 0000000..2d90aee --- /dev/null +++ b/lib/Service/Import/Profile/UserImportGateway.php @@ -0,0 +1,62 @@ +userReadRepository->findByEmail($email); + } + + public function findTenantByUuid(string $uuid): ?array + { + return (new TenantRepository())->findByUuid($uuid); + } + + public function findTenantById(int $id): ?array + { + return (new TenantRepository())->find($id); + } + + public function findRoleByUuid(string $uuid): ?array + { + return (new RoleRepository())->findByUuid($uuid); + } + + public function findRoleById(int $id): ?array + { + return (new RoleRepository())->find($id); + } + + public function findDepartmentByUuid(string $uuid): ?array + { + return (new DepartmentRepository())->findByUuid($uuid); + } + + public function findDepartmentById(int $id): ?array + { + return (new DepartmentRepository())->find($id); + } + + /** + * @param array $input + * @return array + */ + public function createUserFromAdmin(array $input, int $currentUserId): array + { + return $this->userAccountService->createFromAdmin($input, $currentUserId); + } +} diff --git a/lib/Service/Import/Profile/UserImportProfile.php b/lib/Service/Import/Profile/UserImportProfile.php index a72812c..c1dca1b 100644 --- a/lib/Service/Import/Profile/UserImportProfile.php +++ b/lib/Service/Import/Profile/UserImportProfile.php @@ -2,12 +2,6 @@ namespace MintyPHP\Service\Import\Profile; -use MintyPHP\Repository\Access\RoleRepository; -use MintyPHP\Repository\Org\DepartmentRepository; -use MintyPHP\Repository\Tenant\TenantRepository; -use MintyPHP\Repository\User\UserRepository; -use MintyPHP\Service\User\UserService; - class UserImportProfile implements ImportProfileInterface { /** @@ -15,6 +9,10 @@ class UserImportProfile implements ImportProfileInterface */ private ?array $allowedLocales = null; + public function __construct(private readonly UserImportGateway $gateway) + { + } + public function key(): string { return 'users'; @@ -159,7 +157,7 @@ class UserImportProfile implements ImportProfileInterface public function dryRunRow(array $normalized, array $context): array { - $existing = UserRepository::findByEmail((string) ($normalized['email'] ?? '')); + $existing = $this->gateway->findUserByEmail((string) ($normalized['email'] ?? '')); if ($existing) { return ['status' => 'skipped', 'code' => 'email_exists']; } @@ -168,7 +166,7 @@ class UserImportProfile implements ImportProfileInterface public function commitRow(array $normalized, array $context): array { - $existing = UserRepository::findByEmail((string) ($normalized['email'] ?? '')); + $existing = $this->gateway->findUserByEmail((string) ($normalized['email'] ?? '')); if ($existing) { return ['status' => 'skipped', 'code' => 'email_exists']; } @@ -201,11 +199,11 @@ class UserImportProfile implements ImportProfileInterface if ((int) ($normalized['active'] ?? 1) === 1) { $input['active'] = 1; } else { - // UserService::createFromAdmin expects a present-but-null value for inactive state. + // The admin create flow expects a present-but-null value for inactive state. $input['active'] = null; } - $result = UserService::createFromAdmin($input, (int) ($context['current_user_id'] ?? 0)); + $result = $this->gateway->createUserFromAdmin($input, (int) ($context['current_user_id'] ?? 0)); if (!($result['ok'] ?? false)) { $errors = $result['errors'] ?? []; $message = is_array($errors) && isset($errors[0]) ? (string) $errors[0] : t('User can not be registered'); @@ -335,9 +333,9 @@ class UserImportProfile implements ImportProfileInterface { $tenant = null; if ($this->isUuid($value)) { - $tenant = TenantRepository::findByUuid($value); + $tenant = $this->gateway->findTenantByUuid($value); } elseif (ctype_digit($value)) { - $tenant = TenantRepository::find((int) $value); + $tenant = $this->gateway->findTenantById((int) $value); } if (!$tenant) { @@ -350,9 +348,9 @@ class UserImportProfile implements ImportProfileInterface { $role = null; if ($this->isUuid($value)) { - $role = RoleRepository::findByUuid($value); + $role = $this->gateway->findRoleByUuid($value); } elseif (ctype_digit($value)) { - $role = RoleRepository::find((int) $value); + $role = $this->gateway->findRoleById((int) $value); } if (!$role) { @@ -365,9 +363,9 @@ class UserImportProfile implements ImportProfileInterface { $department = null; if ($this->isUuid($value)) { - $department = DepartmentRepository::findByUuid($value); + $department = $this->gateway->findDepartmentByUuid($value); } elseif (ctype_digit($value)) { - $department = DepartmentRepository::find((int) $value); + $department = $this->gateway->findDepartmentById((int) $value); } if (!$department) { diff --git a/lib/Service/Mail/MailLogService.php b/lib/Service/Mail/MailLogService.php index b59b746..29b487c 100644 --- a/lib/Service/Mail/MailLogService.php +++ b/lib/Service/Mail/MailLogService.php @@ -6,13 +6,17 @@ use MintyPHP\Repository\Mail\MailLogRepository; class MailLogService { - public static function listPaged(array $options): array + public function __construct(private readonly MailLogRepository $mailLogRepository) { - return MailLogRepository::listPaged($options); } - public static function find(int $id): ?array + public function listPaged(array $options): array { - return MailLogRepository::find($id); + return $this->mailLogRepository->listPaged($options); + } + + public function find(int $id): ?array + { + return $this->mailLogRepository->find($id); } } diff --git a/lib/Service/Mail/MailService.php b/lib/Service/Mail/MailService.php index 354c728..9e5b691 100644 --- a/lib/Service/Mail/MailService.php +++ b/lib/Service/Mail/MailService.php @@ -2,15 +2,21 @@ namespace MintyPHP\Service\Mail; -use MintyPHP\Repository\Mail\MailLogRepository; use MintyPHP\I18n; -use MintyPHP\Service\Settings\SettingService; -use PHPMailer\PHPMailer\PHPMailer; +use MintyPHP\Repository\Mail\MailLogRepository; +use MintyPHP\Service\Settings\SettingGateway; use PHPMailer\PHPMailer\Exception as MailerException; +use PHPMailer\PHPMailer\PHPMailer; class MailService { - public static function sendTemplate( + public function __construct( + private readonly MailLogRepository $mailLogRepository, + private readonly SettingGateway $settingGateway + ) { + } + + public function sendTemplate( string $template, array $vars, string $to, @@ -18,21 +24,21 @@ class MailService ?string $locale = null ): array { $locale = $locale ?: (I18n::$locale ?? I18n::$defaultLocale); - [$html, $text] = self::renderTemplate($template, $vars, $locale); + [$html, $text] = $this->renderTemplate($template, $vars, $locale); - return self::send($to, $subject, $html, $text, [ + return $this->send($to, $subject, $html, $text, [ 'template' => $template, ]); } - public static function send( + public function send( string $to, string $subject, string $html, string $text, array $meta = [] ): array { - $logId = MailLogRepository::create([ + $logId = $this->mailLogRepository->create([ 'to_email' => $to, 'subject' => $subject, 'template' => $meta['template'] ?? null, @@ -41,13 +47,13 @@ class MailService if (!class_exists(PHPMailer::class)) { if ($logId) { - MailLogRepository::markFailed($logId, 'PHPMailer is not installed'); + $this->mailLogRepository->markFailed($logId, 'PHPMailer is not installed'); } return ['ok' => false, 'error' => 'mailer_missing']; } try { - $mailer = self::createMailer(); + $mailer = $this->createMailer(); $mailer->addAddress($to); $mailer->Subject = $subject; $mailer->Body = $html; @@ -56,18 +62,18 @@ class MailService $mailer->send(); if ($logId) { - MailLogRepository::markSent($logId, $mailer->getLastMessageID() ?: null); + $this->mailLogRepository->markSent($logId, $mailer->getLastMessageID() ?: null); } return ['ok' => true]; } catch (MailerException $e) { if ($logId) { - MailLogRepository::markFailed($logId, $e->getMessage()); + $this->mailLogRepository->markFailed($logId, $e->getMessage()); } return ['ok' => false, 'error' => 'send_failed']; } } - private static function renderTemplate(string $template, array $vars, string $locale): array + private function renderTemplate(string $template, array $vars, string $locale): array { $base = dirname(__DIR__, 3) . '/templates/emails'; $htmlPath = $base . '/' . $locale . '/' . $template . '.html'; @@ -126,17 +132,18 @@ class MailService return [$html, $text]; } - private static function createMailer(): PHPMailer + private function createMailer(): PHPMailer { $mailer = new PHPMailer(true); $mailer->isSMTP(); - $host = SettingService::getSmtpHost() ?? (getenv('SMTP_HOST') ?: ''); - $port = SettingService::getSmtpPort() ?? (int) (getenv('SMTP_PORT') ?: 587); - $user = SettingService::getSmtpUser() ?? (getenv('SMTP_USER') ?: ''); - $pass = SettingService::getSmtpPassword() ?? (getenv('SMTP_PASS') ?: ''); - $secure = SettingService::getSmtpSecure() ?? strtolower((string) (getenv('SMTP_SECURE') ?: 'tls')); - $from = SettingService::getSmtpFrom() ?? (getenv('SMTP_FROM') ?: ($user ?: 'no-reply@localhost')); - $fromName = SettingService::getSmtpFromName() ?? (getenv('SMTP_FROM_NAME') ?: appTitle()); + + $host = $this->settingGateway->getSmtpHost() ?? (getenv('SMTP_HOST') ?: ''); + $port = $this->settingGateway->getSmtpPort() ?? (int) (getenv('SMTP_PORT') ?: 587); + $user = $this->settingGateway->getSmtpUser() ?? (getenv('SMTP_USER') ?: ''); + $pass = $this->settingGateway->getSmtpPassword() ?? (getenv('SMTP_PASS') ?: ''); + $secure = $this->settingGateway->getSmtpSecure() ?? strtolower((string) (getenv('SMTP_SECURE') ?: 'tls')); + $from = $this->settingGateway->getSmtpFrom() ?? (getenv('SMTP_FROM') ?: ($user ?: 'no-reply@localhost')); + $fromName = $this->settingGateway->getSmtpFromName() ?? (getenv('SMTP_FROM_NAME') ?: appTitle()); $mailer->Host = $host; $mailer->Port = $port; @@ -148,6 +155,7 @@ class MailService } $mailer->setFrom($from, $fromName); $mailer->CharSet = 'UTF-8'; + return $mailer; } } diff --git a/lib/Service/Mail/MailServicesFactory.php b/lib/Service/Mail/MailServicesFactory.php new file mode 100644 index 0000000..46fb09c --- /dev/null +++ b/lib/Service/Mail/MailServicesFactory.php @@ -0,0 +1,44 @@ +mailLogRepository ??= new MailLogRepository(); + } + + public function createMailService(): MailService + { + return $this->mailService ??= new MailService( + $this->createMailLogRepository(), + $this->createSettingGateway() + ); + } + + public function createMailLogService(): MailLogService + { + return $this->mailLogService ??= new MailLogService($this->createMailLogRepository()); + } + + private function createSettingGateway(): SettingGateway + { + return $this->settingGateway ??= $this->settingServicesFactory()->createSettingGateway(); + } + + private function settingServicesFactory(): SettingServicesFactory + { + return $this->settingServicesFactory ??= new SettingServicesFactory(); + } +} diff --git a/lib/Service/Org/DepartmentService.php b/lib/Service/Org/DepartmentService.php index a306aa0..b45c981 100644 --- a/lib/Service/Org/DepartmentService.php +++ b/lib/Service/Org/DepartmentService.php @@ -3,34 +3,43 @@ namespace MintyPHP\Service\Org; use MintyPHP\Repository\Org\DepartmentRepository; -use MintyPHP\Repository\Org\UserDepartmentRepository; -use MintyPHP\Service\Settings\SettingService; -use MintyPHP\Service\Tenant\TenantScopeService; +use MintyPHP\Service\Directory\DirectoryScopeGateway; +use MintyPHP\Service\Directory\DirectorySettingsGateway; +use MintyPHP\Service\Settings\SettingServicesFactory; +use MintyPHP\Service\User\UserServicesFactory; class DepartmentService { - public static function list(): array - { - return DepartmentRepository::list(); + public function __construct( + private readonly ?DepartmentRepository $departmentRepository = null, + private readonly ?UserServicesFactory $userServicesFactory = null, + private readonly ?DirectorySettingsGateway $settingsGateway = null, + private readonly ?DirectoryScopeGateway $scopeGateway = null + ) { } - public static function listPaged(array $options): array + public function list(): array + { + return $this->departmentRepository()->list(); + } + + public function listPaged(array $options): array { if (!empty($options['tenantUserId'])) { $tenantUserId = (int) $options['tenantUserId']; - if ($tenantUserId > 0 && TenantScopeService::hasGlobalAccess($tenantUserId)) { + if ($tenantUserId > 0 && $this->scopeGateway()->hasGlobalAccess($tenantUserId)) { unset($options['tenantUserId'], $options['tenantIds']); } } - return DepartmentRepository::listPaged($options); + return $this->departmentRepository()->listPaged($options); } - public static function listByTenantIds(array $tenantIds): array + public function listByTenantIds(array $tenantIds): array { - return DepartmentRepository::listByTenantIds($tenantIds); + return $this->departmentRepository()->listByTenantIds($tenantIds); } - public static function groupActiveByTenantIds(array $tenantIds): array + public function groupActiveByTenantIds(array $tenantIds): array { $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); $tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0)); @@ -38,7 +47,7 @@ class DepartmentService return []; } - $departments = self::listByTenantIds($tenantIds); + $departments = $this->listByTenantIds($tenantIds); if (!$departments) { return []; } @@ -63,15 +72,15 @@ class DepartmentService return $grouped; } - public static function listByIds(array $departmentIds): array + public function listByIds(array $departmentIds): array { - return DepartmentRepository::listByIds($departmentIds); + return $this->departmentRepository()->listByIds($departmentIds); } - public static function listForUserAssignments(array $tenantIds, array $selectedDepartmentIds): array + public function listForUserAssignments(array $tenantIds, array $selectedDepartmentIds): array { - $departments = $tenantIds ? self::listByTenantIds($tenantIds) : self::list(); - $extraDepartments = $selectedDepartmentIds ? self::listByIds($selectedDepartmentIds) : []; + $departments = $tenantIds ? $this->listByTenantIds($tenantIds) : $this->list(); + $extraDepartments = $selectedDepartmentIds ? $this->listByIds($selectedDepartmentIds) : []; if (!$extraDepartments) { return $departments; } @@ -89,27 +98,27 @@ class DepartmentService return array_values($departmentMap); } - public static function findByUuid(string $uuid): ?array + public function findByUuid(string $uuid): ?array { - return DepartmentRepository::findByUuid($uuid); + return $this->departmentRepository()->findByUuid($uuid); } - public static function findById(int $id): ?array + public function findById(int $id): ?array { - return DepartmentRepository::find($id); + return $this->departmentRepository()->find($id); } - public static function createFromAdmin(array $input, int $currentUserId = 0): array + public function createFromAdmin(array $input, int $currentUserId = 0): array { - $form = self::sanitizeBase($input); - $errors = self::validateBase($form); - $warnings = self::warningsForCode($form, 0); + $form = $this->sanitizeBase($input); + $errors = $this->validateBase($form); + $warnings = $this->warningsForCode($form, 0); if ($errors) { return ['ok' => false, 'errors' => $errors, 'warnings' => $warnings, 'form' => $form]; } - $createdId = DepartmentRepository::create([ + $createdId = $this->departmentRepository()->create([ 'description' => $form['description'], 'tenant_id' => $form['tenant_id'], 'code' => $form['code'] ?: null, @@ -122,25 +131,25 @@ class DepartmentService return ['ok' => false, 'errors' => [t('Department can not be created')], 'form' => $form]; } - $createdDepartment = DepartmentRepository::find((int) $createdId); + $createdDepartment = $this->departmentRepository()->find((int) $createdId); $uuid = $createdDepartment['uuid'] ?? null; if (!empty($input['is_default'])) { - SettingService::setDefaultDepartmentId((int) $createdId); + $this->settingsGateway()->setDefaultDepartmentId((int) $createdId); } return ['ok' => true, 'form' => $form, 'warnings' => $warnings, 'uuid' => $uuid, 'id' => (int) $createdId]; } - public static function updateFromAdmin(int $departmentId, array $input, int $currentUserId = 0): array + public function updateFromAdmin(int $departmentId, array $input, int $currentUserId = 0): array { - $form = self::sanitizeBase($input); - $errors = self::validateBase($form); - $warnings = self::warningsForCode($form, $departmentId); + $form = $this->sanitizeBase($input); + $errors = $this->validateBase($form); + $warnings = $this->warningsForCode($form, $departmentId); if ($errors) { return ['ok' => false, 'errors' => $errors, 'warnings' => $warnings, 'form' => $form]; } - $updated = DepartmentRepository::update($departmentId, [ + $updated = $this->departmentRepository()->update($departmentId, [ 'description' => $form['description'], 'tenant_id' => $form['tenant_id'], 'code' => $form['code'] ?: null, @@ -156,42 +165,44 @@ class DepartmentService return ['ok' => true, 'form' => $form, 'warnings' => $warnings]; } - public static function syncTenant(int $departmentId, int $tenantId): int + public function syncTenant(int $departmentId, int $tenantId): int { - $updated = DepartmentRepository::setTenant($departmentId, $tenantId); + $updated = $this->departmentRepository()->setTenant($departmentId, $tenantId); if (!$updated) { return -1; } - return self::cleanupUserAssignments($departmentId); + return $this->cleanupUserAssignments($departmentId); } - public static function syncTenants(int $departmentId, array $tenantIds): int + public function syncTenants(int $departmentId, array $tenantIds): int { $tenantId = (int) ($tenantIds[0] ?? 0); if ($tenantId <= 0) { return -1; } - return self::syncTenant($departmentId, $tenantId); + return $this->syncTenant($departmentId, $tenantId); } - public static function cleanupUserAssignments(int $departmentId): int + public function cleanupUserAssignments(int $departmentId): int { - return UserDepartmentRepository::removeInvalidForDepartment($departmentId); + return $this->userServicesFactory() + ->createUserDepartmentRepository() + ->removeInvalidForDepartment($departmentId); } - public static function deleteByUuid(string $uuid): array + public function deleteByUuid(string $uuid): array { $uuid = trim($uuid); if ($uuid === '') { return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } - $department = DepartmentRepository::findByUuid($uuid); + $department = $this->departmentRepository()->findByUuid($uuid); if (!$department || !isset($department['id'])) { return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } - $deleted = DepartmentRepository::delete((int) $department['id']); + $deleted = $this->departmentRepository()->delete((int) $department['id']); if (!$deleted) { return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; } @@ -199,18 +210,18 @@ class DepartmentService return ['ok' => true, 'department' => $department]; } - private static function sanitizeBase(array $input): array + private function sanitizeBase(array $input): array { return [ 'description' => trim((string) ($input['description'] ?? '')), 'tenant_id' => (int) ($input['tenant_id'] ?? 0), 'code' => trim((string) ($input['code'] ?? '')), 'cost_center' => trim((string) ($input['cost_center'] ?? '')), - 'active' => self::normalizeActive($input['active'] ?? 1), + 'active' => $this->normalizeActive($input['active'] ?? 1), ]; } - private static function validateBase(array $form): array + private function validateBase(array $form): array { $errors = []; if ($form['description'] === '') { @@ -222,17 +233,17 @@ class DepartmentService return $errors; } - private static function warningsForCode(array $form, int $excludeId): array + private function warningsForCode(array $form, int $excludeId): array { $warnings = []; $code = $form['code'] ?? ''; - if ($code !== '' && DepartmentRepository::existsByCode($code, $excludeId)) { + if ($code !== '' && $this->departmentRepository()->existsByCode($code, $excludeId)) { $warnings[] = t('Department code already exists'); } return $warnings; } - private static function normalizeActive($value): int + private function normalizeActive($value): int { $value = strtolower(trim((string) $value)); if (in_array($value, ['0', 'false', 'inactive'], true)) { @@ -240,4 +251,30 @@ class DepartmentService } return 1; } + + private function departmentRepository(): DepartmentRepository + { + return $this->departmentRepository ?? new DepartmentRepository(); + } + + private function userServicesFactory(): UserServicesFactory + { + return $this->userServicesFactory ?? new UserServicesFactory(); + } + + private function settingsGateway(): DirectorySettingsGateway + { + if ($this->settingsGateway instanceof DirectorySettingsGateway) { + return $this->settingsGateway; + } + + return new DirectorySettingsGateway( + (new SettingServicesFactory())->createSettingGateway() + ); + } + + private function scopeGateway(): DirectoryScopeGateway + { + return $this->scopeGateway ?? new DirectoryScopeGateway(); + } } diff --git a/lib/Service/Scheduler/Handler/ScheduledJobHandlerInterface.php b/lib/Service/Scheduler/Handler/ScheduledJobHandlerInterface.php index 5c774ee..f24d58b 100644 --- a/lib/Service/Scheduler/Handler/ScheduledJobHandlerInterface.php +++ b/lib/Service/Scheduler/Handler/ScheduledJobHandlerInterface.php @@ -8,7 +8,7 @@ interface ScheduledJobHandlerInterface * Returns the job definition (metadata + schedule defaults). * * The job_key is NOT included here – it is provided as the key in the - * registry handler map (ScheduledJobRegistry::handlers()). + * registry handler map in ScheduledJobRegistry. * * Required keys: * label (string) Human-readable name shown in admin UI. @@ -22,7 +22,7 @@ interface ScheduledJobHandlerInterface * default_catchup_once (int) 1 = catch up once after downtime (default). * allowed_schedule_types (array) Subset of ['hourly','daily','weekly']. */ - public static function definition(): array; + public function definition(): array; /** * Executes the job and returns a normalized result envelope. @@ -42,5 +42,5 @@ interface ScheduledJobHandlerInterface * error_message: human-readable detail, null on success (max 255 chars) * result: job-specific payload, empty array if nothing to report */ - public static function execute(?int $actorUserId): array; + public function execute(?int $actorUserId): array; } diff --git a/lib/Service/Scheduler/Handler/UserLifecycleJobHandler.php b/lib/Service/Scheduler/Handler/UserLifecycleJobHandler.php index b12f5df..2e237af 100644 --- a/lib/Service/Scheduler/Handler/UserLifecycleJobHandler.php +++ b/lib/Service/Scheduler/Handler/UserLifecycleJobHandler.php @@ -6,7 +6,11 @@ use MintyPHP\Service\User\UserLifecycleService; class UserLifecycleJobHandler implements ScheduledJobHandlerInterface { - public static function definition(): array + public function __construct(private readonly UserLifecycleService $userLifecycleService) + { + } + + public function definition(): array { return [ 'label' => 'User lifecycle run', @@ -22,9 +26,9 @@ class UserLifecycleJobHandler implements ScheduledJobHandlerInterface ]; } - public static function execute(?int $actorUserId): array + public function execute(?int $actorUserId): array { - $result = UserLifecycleService::run($actorUserId !== null && $actorUserId > 0 ? $actorUserId : null); + $result = $this->userLifecycleService->run($actorUserId !== null && $actorUserId > 0 ? $actorUserId : null); if (!($result['ok'] ?? false)) { $errorCode = (string) ($result['error'] ?? 'job_failed'); @@ -33,7 +37,7 @@ class UserLifecycleJobHandler implements ScheduledJobHandlerInterface 'status' => $status, 'error_code' => $errorCode, 'error_message' => null, - 'result' => self::formatResult($result), + 'result' => $this->formatResult($result), ]; } @@ -41,11 +45,11 @@ class UserLifecycleJobHandler implements ScheduledJobHandlerInterface 'status' => 'success', 'error_code' => null, 'error_message' => null, - 'result' => self::formatResult($result), + 'result' => $this->formatResult($result), ]; } - private static function formatResult(array $result): array + private function formatResult(array $result): array { return [ 'run_uuid' => (string) ($result['run_uuid'] ?? ''), diff --git a/lib/Service/Scheduler/ScheduleCalculator.php b/lib/Service/Scheduler/ScheduleCalculator.php index 9b17cea..bf3de4b 100644 --- a/lib/Service/Scheduler/ScheduleCalculator.php +++ b/lib/Service/Scheduler/ScheduleCalculator.php @@ -4,7 +4,7 @@ namespace MintyPHP\Service\Scheduler; class ScheduleCalculator { - public static function normalizeTimezone(?string $timezone): string + public function normalizeTimezone(?string $timezone): string { $timezone = trim((string) $timezone); if ($timezone === '') { @@ -18,15 +18,15 @@ class ScheduleCalculator } } - public static function normalizeScheduleType(?string $type): string + public function normalizeScheduleType(?string $type): string { $type = strtolower(trim((string) $type)); return in_array($type, ['hourly', 'daily', 'weekly'], true) ? $type : 'daily'; } - public static function normalizeInterval(string $type, int $value): int + public function normalizeInterval(string $type, int $value): int { - $type = self::normalizeScheduleType($type); + $type = $this->normalizeScheduleType($type); if ($type === 'hourly') { return max(1, min(24, $value)); } @@ -36,7 +36,7 @@ class ScheduleCalculator return max(1, min(365, $value)); } - public static function normalizeTime(?string $time): ?string + public function normalizeTime(?string $time): ?string { $time = trim((string) $time); if ($time === '') { @@ -53,9 +53,9 @@ class ScheduleCalculator return sprintf('%02d:%02d', $hour, $minute); } - public static function normalizeWeekdaysCsv(mixed $value): ?string + public function normalizeWeekdaysCsv(mixed $value): ?string { - $list = self::parseWeekdays($value); + $list = $this->parseWeekdays($value); if (!$list) { return null; } @@ -65,7 +65,7 @@ class ScheduleCalculator /** * @return array */ - public static function parseWeekdays(mixed $value): array + public function parseWeekdays(mixed $value): array { $raw = []; if (is_array($value)) { @@ -104,12 +104,12 @@ class ScheduleCalculator * Returns null if the schedule is misconfigured (e.g. missing schedule_time for daily/weekly) * or if no valid next run could be found within the search window. */ - public static function calculateNextRunUtc(array $job, ?\DateTimeImmutable $referenceUtc = null): ?\DateTimeImmutable + public function calculateNextRunUtc(array $job, ?\DateTimeImmutable $referenceUtc = null): ?\DateTimeImmutable { $referenceUtc = $referenceUtc ?? new \DateTimeImmutable('now', new \DateTimeZone('UTC')); - $type = self::normalizeScheduleType((string) ($job['schedule_type'] ?? 'daily')); - $interval = self::normalizeInterval($type, (int) ($job['schedule_interval'] ?? 1)); - $timezone = new \DateTimeZone(self::normalizeTimezone((string) ($job['timezone'] ?? ''))); + $type = $this->normalizeScheduleType((string) ($job['schedule_type'] ?? 'daily')); + $interval = $this->normalizeInterval($type, (int) ($job['schedule_interval'] ?? 1)); + $timezone = new \DateTimeZone($this->normalizeTimezone((string) ($job['timezone'] ?? ''))); $localReference = $referenceUtc->setTimezone($timezone); if ($type === 'hourly') { @@ -120,7 +120,7 @@ class ScheduleCalculator return $candidate->setTimezone(new \DateTimeZone('UTC')); } - $time = self::normalizeTime((string) ($job['schedule_time'] ?? '')); + $time = $this->normalizeTime((string) ($job['schedule_time'] ?? '')); if ($time === null) { return null; } @@ -138,7 +138,7 @@ class ScheduleCalculator // week interval. The search window is 1110 days (~3 years), which comfortably covers // the maximum weekly interval of 52 weeks × 7 days/week = 364 days, with margin for // weekday alignment and DST edge cases. - $weekdays = self::parseWeekdays((string) ($job['schedule_weekdays_csv'] ?? '')); + $weekdays = $this->parseWeekdays((string) ($job['schedule_weekdays_csv'] ?? '')); if (!$weekdays) { $weekdays = [1]; } diff --git a/lib/Service/Scheduler/ScheduledJobRegistry.php b/lib/Service/Scheduler/ScheduledJobRegistry.php index a2812bd..d13a300 100644 --- a/lib/Service/Scheduler/ScheduledJobRegistry.php +++ b/lib/Service/Scheduler/ScheduledJobRegistry.php @@ -4,39 +4,42 @@ namespace MintyPHP\Service\Scheduler; use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface; use MintyPHP\Service\Scheduler\Handler\UserLifecycleJobHandler; +use MintyPHP\Service\User\UserLifecycleService; class ScheduledJobRegistry { public const USER_LIFECYCLE_RUN = 'user_lifecycle_run'; /** - * Maps job_key => handler class name (must implement ScheduledJobHandlerInterface). + * Maps job_key => handler instance (must implement ScheduledJobHandlerInterface). * * To register a new job: * 1. Create lib/Service/Scheduler/Handler/YourJobHandler.php implementing the interface. * 2. Add a public const for the job key above. - * 3. Add one line here: self::YOUR_JOB_KEY => YourJobHandler::class, + * 3. Add one line in __construct(): self::YOUR_JOB_KEY => new YourJobHandler(...), * * No other file needs to be changed. * - * @return array> + * @var array */ - private static function handlers(): array + private array $handlers; + + public function __construct(UserLifecycleService $userLifecycleService) { - return [ - self::USER_LIFECYCLE_RUN => UserLifecycleJobHandler::class, + $this->handlers = [ + self::USER_LIFECYCLE_RUN => new UserLifecycleJobHandler($userLifecycleService), ]; } /** * Returns all job definitions keyed by job_key. - * Consumed by ScheduledJobService::ensureSystemJobs() to sync registry with the database. + * Consumed by ScheduledJobService->ensureSystemJobs() to sync registry with the database. */ - public static function definitions(): array + public function definitions(): array { $result = []; - foreach (self::handlers() as $jobKey => $handlerClass) { - $result[$jobKey] = $handlerClass::definition(); + foreach ($this->handlers as $jobKey => $handler) { + $result[$jobKey] = $handler->definition(); } return $result; } @@ -44,14 +47,13 @@ class ScheduledJobRegistry /** * Returns the definition for a single job key, or null if not registered. */ - public static function get(string $jobKey): ?array + public function get(string $jobKey): ?array { $jobKey = trim($jobKey); if ($jobKey === '') { return null; } - $handlers = self::handlers(); - return isset($handlers[$jobKey]) ? $handlers[$jobKey]::definition() : null; + return isset($this->handlers[$jobKey]) ? $this->handlers[$jobKey]->definition() : null; } /** @@ -63,10 +65,9 @@ class ScheduledJobRegistry * @param int|null $actorUserId Null for scheduler-triggered runs; user ID for manual runs. * @return array{status:string,error_code:?string,error_message:?string,result:array} */ - public static function execute(string $jobKey, ?int $actorUserId): array + public function execute(string $jobKey, ?int $actorUserId): array { - $handlers = self::handlers(); - if (!isset($handlers[$jobKey])) { + if (!isset($this->handlers[$jobKey])) { return [ 'status' => 'failed', 'error_code' => 'job_not_supported', @@ -74,6 +75,6 @@ class ScheduledJobRegistry 'result' => [], ]; } - return $handlers[$jobKey]::execute($actorUserId); + return $this->handlers[$jobKey]->execute($actorUserId); } } diff --git a/lib/Service/Scheduler/ScheduledJobService.php b/lib/Service/Scheduler/ScheduledJobService.php index ddb09ed..95a46d7 100644 --- a/lib/Service/Scheduler/ScheduledJobService.php +++ b/lib/Service/Scheduler/ScheduledJobService.php @@ -9,59 +9,67 @@ class ScheduledJobService { private const RUN_RETENTION_DAYS = 90; - public static function ensureSystemJobs(): void + public function __construct( + private readonly ScheduledJobRepository $scheduledJobRepository, + private readonly ScheduledJobRunRepository $scheduledJobRunRepository, + private readonly ScheduledJobRegistry $scheduledJobRegistry, + private readonly ScheduleCalculator $scheduleCalculator + ) { + } + + public function ensureSystemJobs(): void { - $definitions = ScheduledJobRegistry::definitions(); + $definitions = $this->scheduledJobRegistry->definitions(); $nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); foreach ($definitions as $jobKey => $definition) { - $existing = ScheduledJobRepository::findByKey($jobKey); - $job = self::buildDefaultJob($definition, $jobKey, $nowUtc); + $existing = $this->scheduledJobRepository->findByKey($jobKey); + $job = $this->buildDefaultJob($definition, $jobKey, $nowUtc); if (!$existing) { - ScheduledJobRepository::create($job); + $this->scheduledJobRepository->create($job); continue; } - $normalized = self::normalizeStoredJob($existing, $definition); - if (!self::jobsDiffer($existing, $normalized)) { + $normalized = $this->normalizeStoredJob($existing, $definition); + if (!$this->jobsDiffer($existing, $normalized)) { continue; } - ScheduledJobRepository::updateJobMeta((int) $existing['id'], $normalized); + $this->scheduledJobRepository->updateJobMeta((int) $existing['id'], $normalized); } } - public static function listPaged(array $filters): array + public function listPaged(array $filters): array { - self::ensureSystemJobs(); - return ScheduledJobRepository::listPaged($filters); + $this->ensureSystemJobs(); + return $this->scheduledJobRepository->listPaged($filters); } - public static function find(int $id): ?array + public function find(int $id): ?array { - self::ensureSystemJobs(); - return ScheduledJobRepository::find($id); + $this->ensureSystemJobs(); + return $this->scheduledJobRepository->find($id); } - public static function updateFromAdmin(int $id, array $input): array + public function updateFromAdmin(int $id, array $input): array { - $job = self::find($id); + $job = $this->find($id); if (!$job) { - return ['ok' => false, 'errors' => [t('Scheduled job not found')]]; + return ['ok' => false, 'errors' => [$this->translate('Scheduled job not found')]]; } - $definition = ScheduledJobRegistry::get((string) ($job['job_key'] ?? '')); + $definition = $this->scheduledJobRegistry->get((string) ($job['job_key'] ?? '')); if (!$definition) { - return ['ok' => false, 'errors' => [t('Scheduled job is not registered')]]; + return ['ok' => false, 'errors' => [$this->translate('Scheduled job is not registered')]]; } $form = [ 'enabled' => isset($input['enabled']) ? 1 : 0, - 'timezone' => ScheduleCalculator::normalizeTimezone((string) ($input['timezone'] ?? ($job['timezone'] ?? ''))), - 'schedule_type' => ScheduleCalculator::normalizeScheduleType((string) ($input['schedule_type'] ?? ($job['schedule_type'] ?? 'daily'))), + 'timezone' => $this->scheduleCalculator->normalizeTimezone((string) ($input['timezone'] ?? ($job['timezone'] ?? ''))), + 'schedule_type' => $this->scheduleCalculator->normalizeScheduleType((string) ($input['schedule_type'] ?? ($job['schedule_type'] ?? 'daily'))), 'schedule_interval' => (int) ($input['schedule_interval'] ?? ($job['schedule_interval'] ?? 1)), - 'schedule_time' => ScheduleCalculator::normalizeTime((string) ($input['schedule_time'] ?? ($job['schedule_time'] ?? ''))), - 'schedule_weekdays_csv' => ScheduleCalculator::normalizeWeekdaysCsv( + 'schedule_time' => $this->scheduleCalculator->normalizeTime((string) ($input['schedule_time'] ?? ($job['schedule_time'] ?? ''))), + 'schedule_weekdays_csv' => $this->scheduleCalculator->normalizeWeekdaysCsv( array_key_exists('schedule_weekdays', $input) ? $input['schedule_weekdays'] : ($job['schedule_weekdays_csv'] ?? '') @@ -69,18 +77,18 @@ class ScheduledJobService 'catchup_once' => isset($input['catchup_once']) ? 1 : 0, ]; - $errors = self::validateForm($form, $definition); + $errors = $this->validateForm($form, $definition); if ($errors) { return ['ok' => false, 'errors' => $errors, 'form' => $form, 'job' => $job]; } $nextRunAt = null; if ((int) $form['enabled'] === 1) { - $nextRun = ScheduleCalculator::calculateNextRunUtc($form, new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); + $nextRun = $this->scheduleCalculator->calculateNextRunUtc($form, new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); $nextRunAt = $nextRun ? $nextRun->format('Y-m-d H:i:s') : null; } - $updated = ScheduledJobRepository::updateJobMeta((int) $job['id'], [ + $updated = $this->scheduledJobRepository->updateJobMeta((int) $job['id'], [ 'label' => (string) ($definition['label'] ?? (string) ($job['label'] ?? '')), 'description' => (string) ($definition['description'] ?? (string) ($job['description'] ?? '')), 'enabled' => (int) $form['enabled'], @@ -94,95 +102,95 @@ class ScheduledJobService ]); if (!$updated) { - return ['ok' => false, 'errors' => [t('Scheduled job could not be saved')], 'form' => $form, 'job' => $job]; + return ['ok' => false, 'errors' => [$this->translate('Scheduled job could not be saved')], 'form' => $form, 'job' => $job]; } - $fresh = self::find((int) $job['id']); + $fresh = $this->find((int) $job['id']); return ['ok' => true, 'job' => $fresh, 'form' => $form]; } - public static function listRunsByJobId(int $jobId, array $filters): array + public function listRunsByJobId(int $jobId, array $filters): array { - return ScheduledJobRunRepository::listPagedByJobId($jobId, $filters); + return $this->scheduledJobRunRepository->listPagedByJobId($jobId, $filters); } - public static function purgeRunsExpired(): int + public function purgeRunsExpired(): int { - return ScheduledJobRunRepository::purgeOlderThanDays(self::RUN_RETENTION_DAYS); + return $this->scheduledJobRunRepository->purgeOlderThanDays(self::RUN_RETENTION_DAYS); } - public static function scheduleSummary(array $job): string + public function scheduleSummary(array $job): string { - $type = ScheduleCalculator::normalizeScheduleType((string) ($job['schedule_type'] ?? 'daily')); + $type = $this->scheduleCalculator->normalizeScheduleType((string) ($job['schedule_type'] ?? 'daily')); $interval = (int) ($job['schedule_interval'] ?? 1); $time = (string) ($job['schedule_time'] ?? ''); if ($type === 'hourly') { - return sprintf(t('Every %d hour(s)'), max(1, $interval)); + return sprintf($this->translate('Every %d hour(s)'), max(1, $interval)); } if ($type === 'daily') { - return sprintf(t('Every %d day(s) at %s'), max(1, $interval), $time !== '' ? $time : '--:--'); + return sprintf($this->translate('Every %d day(s) at %s'), max(1, $interval), $time !== '' ? $time : '--:--'); } - $weekdays = ScheduleCalculator::parseWeekdays((string) ($job['schedule_weekdays_csv'] ?? '')); + $weekdays = $this->scheduleCalculator->parseWeekdays((string) ($job['schedule_weekdays_csv'] ?? '')); $labels = []; foreach ($weekdays as $day) { - $labels[] = self::weekdayLabel($day); + $labels[] = $this->weekdayLabel($day); } - $weekdayText = $labels ? implode(', ', $labels) : t('No weekdays'); + $weekdayText = $labels ? implode(', ', $labels) : $this->translate('No weekdays'); return sprintf( - t('Every %d week(s) on %s at %s'), + $this->translate('Every %d week(s) on %s at %s'), max(1, $interval), $weekdayText, $time !== '' ? $time : '--:--' ); } - public static function weekdaysFromCsv(?string $csv): array + public function weekdaysFromCsv(?string $csv): array { - return ScheduleCalculator::parseWeekdays((string) ($csv ?? '')); + return $this->scheduleCalculator->parseWeekdays((string) ($csv ?? '')); } - private static function validateForm(array $form, array $definition): array + private function validateForm(array $form, array $definition): array { $errors = []; $allowedTypes = $definition['allowed_schedule_types'] ?? ['hourly', 'daily', 'weekly']; $type = (string) ($form['schedule_type'] ?? 'daily'); if (!in_array($type, $allowedTypes, true)) { - $errors[] = t('Schedule type is invalid'); + $errors[] = $this->translate('Schedule type is invalid'); return $errors; } $interval = (int) ($form['schedule_interval'] ?? 0); if ($type === 'hourly' && ($interval < 1 || $interval > 24)) { - $errors[] = t('Hourly interval must be between 1 and 24'); + $errors[] = $this->translate('Hourly interval must be between 1 and 24'); } elseif ($type === 'daily' && ($interval < 1 || $interval > 365)) { - $errors[] = t('Daily interval must be between 1 and 365'); + $errors[] = $this->translate('Daily interval must be between 1 and 365'); } elseif ($type === 'weekly' && ($interval < 1 || $interval > 52)) { - $errors[] = t('Weekly interval must be between 1 and 52'); + $errors[] = $this->translate('Weekly interval must be between 1 and 52'); } $timezone = trim((string) ($form['timezone'] ?? '')); try { new \DateTimeZone($timezone); } catch (\Throwable $exception) { - $errors[] = t('Timezone is invalid'); + $errors[] = $this->translate('Timezone is invalid'); } if (in_array($type, ['daily', 'weekly'], true) && $form['schedule_time'] === null) { - $errors[] = t('Schedule time is required'); + $errors[] = $this->translate('Schedule time is required'); } if ($type === 'weekly') { - $weekdays = ScheduleCalculator::parseWeekdays((string) ($form['schedule_weekdays_csv'] ?? '')); + $weekdays = $this->scheduleCalculator->parseWeekdays((string) ($form['schedule_weekdays_csv'] ?? '')); if (!$weekdays) { - $errors[] = t('At least one weekday is required for weekly schedule'); + $errors[] = $this->translate('At least one weekday is required for weekly schedule'); } } return $errors; } - private static function buildDefaultJob(array $definition, string $jobKey, \DateTimeImmutable $nowUtc): array + private function buildDefaultJob(array $definition, string $jobKey, \DateTimeImmutable $nowUtc): array { $type = (string) ($definition['default_schedule_type'] ?? 'daily'); $job = [ @@ -190,40 +198,40 @@ class ScheduledJobService 'label' => (string) ($definition['label'] ?? $jobKey), 'description' => (string) ($definition['description'] ?? ''), 'enabled' => (int) ($definition['default_enabled'] ?? 1), - 'timezone' => ScheduleCalculator::normalizeTimezone((string) ($definition['default_timezone'] ?? '')), - 'schedule_type' => ScheduleCalculator::normalizeScheduleType($type), - 'schedule_interval' => ScheduleCalculator::normalizeInterval($type, (int) ($definition['default_schedule_interval'] ?? 1)), - 'schedule_time' => ScheduleCalculator::normalizeTime((string) ($definition['default_schedule_time'] ?? '')), - 'schedule_weekdays_csv' => ScheduleCalculator::normalizeWeekdaysCsv($definition['default_schedule_weekdays_csv'] ?? null), + 'timezone' => $this->scheduleCalculator->normalizeTimezone((string) ($definition['default_timezone'] ?? '')), + 'schedule_type' => $this->scheduleCalculator->normalizeScheduleType($type), + 'schedule_interval' => $this->scheduleCalculator->normalizeInterval($type, (int) ($definition['default_schedule_interval'] ?? 1)), + 'schedule_time' => $this->scheduleCalculator->normalizeTime((string) ($definition['default_schedule_time'] ?? '')), + 'schedule_weekdays_csv' => $this->scheduleCalculator->normalizeWeekdaysCsv($definition['default_schedule_weekdays_csv'] ?? null), 'catchup_once' => (int) ($definition['default_catchup_once'] ?? 1), 'next_run_at' => null, ]; if ((int) $job['enabled'] === 1) { - $nextRun = ScheduleCalculator::calculateNextRunUtc($job, $nowUtc); + $nextRun = $this->scheduleCalculator->calculateNextRunUtc($job, $nowUtc); $job['next_run_at'] = $nextRun ? $nextRun->format('Y-m-d H:i:s') : null; } return $job; } - private static function normalizeStoredJob(array $existing, array $definition): array + private function normalizeStoredJob(array $existing, array $definition): array { - $type = ScheduleCalculator::normalizeScheduleType((string) ($existing['schedule_type'] ?? ($definition['default_schedule_type'] ?? 'daily'))); + $type = $this->scheduleCalculator->normalizeScheduleType((string) ($existing['schedule_type'] ?? ($definition['default_schedule_type'] ?? 'daily'))); $normalized = [ 'label' => (string) ($definition['label'] ?? (string) ($existing['label'] ?? '')), 'description' => (string) ($definition['description'] ?? (string) ($existing['description'] ?? '')), 'enabled' => (int) ($existing['enabled'] ?? ($definition['default_enabled'] ?? 1)), - 'timezone' => ScheduleCalculator::normalizeTimezone((string) ($existing['timezone'] ?? ($definition['default_timezone'] ?? ''))), + 'timezone' => $this->scheduleCalculator->normalizeTimezone((string) ($existing['timezone'] ?? ($definition['default_timezone'] ?? ''))), 'schedule_type' => $type, - 'schedule_interval' => ScheduleCalculator::normalizeInterval($type, (int) ($existing['schedule_interval'] ?? ($definition['default_schedule_interval'] ?? 1))), - 'schedule_time' => ScheduleCalculator::normalizeTime((string) ($existing['schedule_time'] ?? ($definition['default_schedule_time'] ?? ''))), - 'schedule_weekdays_csv' => ScheduleCalculator::normalizeWeekdaysCsv( + 'schedule_interval' => $this->scheduleCalculator->normalizeInterval($type, (int) ($existing['schedule_interval'] ?? ($definition['default_schedule_interval'] ?? 1))), + 'schedule_time' => $this->scheduleCalculator->normalizeTime((string) ($existing['schedule_time'] ?? ($definition['default_schedule_time'] ?? ''))), + 'schedule_weekdays_csv' => $this->scheduleCalculator->normalizeWeekdaysCsv( (string) ($existing['schedule_weekdays_csv'] ?? ($definition['default_schedule_weekdays_csv'] ?? '')) ), 'catchup_once' => (int) ($existing['catchup_once'] ?? ($definition['default_catchup_once'] ?? 1)), 'next_run_at' => (string) ($existing['next_run_at'] ?? ''), ]; if ($normalized['next_run_at'] === '' && (int) $normalized['enabled'] === 1) { - $nextRun = ScheduleCalculator::calculateNextRunUtc($normalized, new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); + $nextRun = $this->scheduleCalculator->calculateNextRunUtc($normalized, new \DateTimeImmutable('now', new \DateTimeZone('UTC'))); $normalized['next_run_at'] = $nextRun ? $nextRun->format('Y-m-d H:i:s') : null; } else { $normalized['next_run_at'] = $normalized['next_run_at'] !== '' ? $normalized['next_run_at'] : null; @@ -231,7 +239,7 @@ class ScheduledJobService return $normalized; } - private static function jobsDiffer(array $existing, array $normalized): bool + private function jobsDiffer(array $existing, array $normalized): bool { $keys = [ 'label', @@ -255,17 +263,28 @@ class ScheduledJobService return false; } - private static function weekdayLabel(int $weekday): string + private function weekdayLabel(int $weekday): string { return match ($weekday) { - 1 => t('Mon'), - 2 => t('Tue'), - 3 => t('Wed'), - 4 => t('Thu'), - 5 => t('Fri'), - 6 => t('Sat'), - 7 => t('Sun'), + 1 => $this->translate('Mon'), + 2 => $this->translate('Tue'), + 3 => $this->translate('Wed'), + 4 => $this->translate('Thu'), + 5 => $this->translate('Fri'), + 6 => $this->translate('Sat'), + 7 => $this->translate('Sun'), default => '-', }; } + + private function translate(string $text, mixed ...$args): string + { + if (function_exists('t')) { + return t($text, ...$args); + } + if ($args === []) { + return $text; + } + return vsprintf($text, array_map(static fn ($arg) => (string) $arg, $args)); + } } diff --git a/lib/Service/Scheduler/SchedulerRunService.php b/lib/Service/Scheduler/SchedulerRunService.php index 2621908..641b03b 100644 --- a/lib/Service/Scheduler/SchedulerRunService.php +++ b/lib/Service/Scheduler/SchedulerRunService.php @@ -14,15 +14,25 @@ use MintyPHP\Repository\Support\RepoQuery; * Acquires a MySQL advisory lock before any work to ensure only one runner * process is active at a time (safe for minute-by-minute cron invocations). * Individual jobs are dispatched via ScheduledJobRegistry, and every execution - * attempt – including skips and failures – is written to the run log. + * attempt - including skips and failures - is written to the run log. */ class SchedulerRunService { private const RUNNER_LOCK_NAME = 'scheduled_jobs_runner'; - public static function runDueJobs(int $limit = 20): array + public function __construct( + private readonly ScheduledJobService $scheduledJobService, + private readonly ScheduledJobRepository $scheduledJobRepository, + private readonly ScheduledJobRunRepository $scheduledJobRunRepository, + private readonly SchedulerRuntimeRepository $schedulerRuntimeRepository, + private readonly ScheduledJobRegistry $scheduledJobRegistry, + private readonly ScheduleCalculator $scheduleCalculator + ) { + } + + public function runDueJobs(int $limit = 20): array { - ScheduledJobService::ensureSystemJobs(); + $this->scheduledJobService->ensureSystemJobs(); $startedAt = microtime(true); $result = [ 'ok' => true, @@ -34,19 +44,19 @@ class SchedulerRunService 'skipped' => 0, ]; - if (!self::acquireRunnerLock()) { + if (!$this->acquireRunnerLock()) { $result['ok'] = false; $result['error'] = 'lock_not_acquired'; - $result['duration_ms'] = self::durationMs($startedAt); - self::updateRuntimeHeartbeat('lock_not_acquired', 'lock_not_acquired'); + $result['duration_ms'] = $this->durationMs($startedAt); + $this->updateRuntimeHeartbeat('lock_not_acquired', 'lock_not_acquired'); return $result; } try { $nowUtc = gmdate('Y-m-d H:i:s'); - $dueJobs = ScheduledJobRepository::listDueJobs($nowUtc, $limit); + $dueJobs = $this->scheduledJobRepository->listDueJobs($nowUtc, $limit); foreach ($dueJobs as $job) { - $run = self::runOne($job, 'scheduler', null, false); + $run = $this->runOne($job, 'scheduler', null, false); $result['processed']++; $status = (string) ($run['status'] ?? 'failed'); if ($status === 'success') { @@ -61,46 +71,46 @@ class SchedulerRunService $result['ok'] = false; $result['error'] = 'unexpected_error'; } finally { - self::releaseRunnerLock(); - $result['duration_ms'] = self::durationMs($startedAt); + $this->releaseRunnerLock(); + $result['duration_ms'] = $this->durationMs($startedAt); if ($result['ok']) { - self::updateRuntimeHeartbeat('ok', null); + $this->updateRuntimeHeartbeat('ok', null); } else { - $errorCode = self::nullableString($result['error'] ?? null) ?? 'unexpected_error'; - self::updateRuntimeHeartbeat('unexpected_error', $errorCode); + $errorCode = $this->nullableString($result['error']) ?? 'unexpected_error'; + $this->updateRuntimeHeartbeat('unexpected_error', $errorCode); } } return $result; } - public static function runJobNow(int $jobId, int $actorUserId): array + public function runJobNow(int $jobId, int $actorUserId): array { - ScheduledJobService::ensureSystemJobs(); + $this->scheduledJobService->ensureSystemJobs(); if ($jobId <= 0 || $actorUserId <= 0) { return ['ok' => false, 'error' => 'invalid_request']; } - if (!self::acquireRunnerLock()) { + if (!$this->acquireRunnerLock()) { return ['ok' => false, 'error' => 'lock_not_acquired']; } try { - $job = ScheduledJobRepository::find($jobId); + $job = $this->scheduledJobRepository->find($jobId); if (!$job) { return ['ok' => false, 'error' => 'job_not_found']; } - $run = self::runOne($job, 'manual', $actorUserId, true); + $run = $this->runOne($job, 'manual', $actorUserId, true); return [ 'ok' => in_array((string) ($run['status'] ?? ''), ['success', 'skipped'], true), 'status' => (string) ($run['status'] ?? 'failed'), 'error' => (string) ($run['error_code'] ?? ''), ]; } finally { - self::releaseRunnerLock(); + $this->releaseRunnerLock(); } } - private static function runOne(array $job, string $triggerType, ?int $actorUserId, bool $force): array + private function runOne(array $job, string $triggerType, ?int $actorUserId, bool $force): array { $jobId = (int) ($job['id'] ?? 0); $nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC')); @@ -115,9 +125,9 @@ class SchedulerRunService return ['status' => 'skipped', 'error_code' => 'job_disabled', 'error_message' => null]; } - $markedRunning = ScheduledJobRepository::markRunning($jobId, $startedAtUtc); + $markedRunning = $this->scheduledJobRepository->markRunning($jobId, $startedAtUtc); if (!$markedRunning) { - self::insertRunLog([ + $this->insertRunLog([ 'job_id' => $jobId, 'job_key' => (string) ($job['job_key'] ?? ''), 'trigger_type' => $triggerType, @@ -140,17 +150,17 @@ class SchedulerRunService $resultPayload = []; try { - $definition = ScheduledJobRegistry::get((string) ($job['job_key'] ?? '')); + $definition = $this->scheduledJobRegistry->get((string) ($job['job_key'] ?? '')); if (!$definition) { $status = 'failed'; $errorCode = 'job_not_registered'; } else { - $execution = ScheduledJobRegistry::execute((string) $job['job_key'], $actorUserId); + $execution = $this->scheduledJobRegistry->execute((string) $job['job_key'], $actorUserId); $status = in_array((string) $execution['status'], ['success', 'failed', 'skipped'], true) ? (string) $execution['status'] : 'failed'; - $errorCode = self::nullableString($execution['error_code']); - $errorMessage = self::nullableString($execution['error_message']); + $errorCode = $this->nullableString($execution['error_code']); + $errorMessage = $this->nullableString($execution['error_message']); $resultPayload = $execution['result']; } } catch (\Throwable $exception) { @@ -168,14 +178,14 @@ class SchedulerRunService if ($triggerType === 'scheduler' && (int) ($job['catchup_once'] ?? 1) === 0 && !empty($job['next_run_at'])) { // catchup_once = 0: advance next_run_at strictly from the originally scheduled // time, skipping any windows that already passed. This prevents a backlog storm - // after downtime – missed slots are dropped, only the next future slot is kept. + // after downtime - missed slots are dropped, only the next future slot is kept. // The guard cap (500) prevents an infinite loop if calculateNextRunUtc has a bug // and keeps returning a time that is still in the past. - $reference = self::parseUtc((string) $job['next_run_at']) ?? $finishedAt; - $next = ScheduleCalculator::calculateNextRunUtc($job, $reference); + $reference = $this->parseUtc((string) $job['next_run_at']) ?? $finishedAt; + $next = $this->scheduleCalculator->calculateNextRunUtc($job, $reference); $guard = 0; while ($next && $next <= $finishedAt && $guard < 500) { - $next = ScheduleCalculator::calculateNextRunUtc($job, $next); + $next = $this->scheduleCalculator->calculateNextRunUtc($job, $next); $guard++; } $nextRunUtc = $next ? $next->format('Y-m-d H:i:s') : null; @@ -183,12 +193,12 @@ class SchedulerRunService // catchup_once = 1 (default): calculate next_run_at from the actual finish time. // If a run was delayed or a slot was missed, exactly one catch-up run is scheduled // starting from now, then normal scheduling resumes. - $next = ScheduleCalculator::calculateNextRunUtc($job, $finishedAt); + $next = $this->scheduleCalculator->calculateNextRunUtc($job, $finishedAt); $nextRunUtc = $next ? $next->format('Y-m-d H:i:s') : null; } } - ScheduledJobRepository::finishRun( + $this->scheduledJobRepository->finishRun( $jobId, $status, $startedAtUtc, @@ -198,7 +208,7 @@ class SchedulerRunService $errorMessage ); - self::insertRunLog([ + $this->insertRunLog([ 'job_id' => $jobId, 'job_key' => (string) ($job['job_key'] ?? ''), 'trigger_type' => $triggerType, @@ -209,16 +219,16 @@ class SchedulerRunService 'duration_ms' => $durationMs, 'error_code' => $errorCode, 'error_message' => $errorMessage, - 'result_json' => self::encodeResult($resultPayload), + 'result_json' => $this->encodeResult($resultPayload), ]); return ['status' => $status, 'error_code' => $errorCode, 'error_message' => $errorMessage]; } - private static function insertRunLog(array $data): void + private function insertRunLog(array $data): void { try { - ScheduledJobRunRepository::create([ + $this->scheduledJobRunRepository->create([ 'run_uuid' => RepoQuery::uuidV4(), 'job_id' => (int) ($data['job_id'] ?? 0), 'job_key' => (string) ($data['job_key'] ?? ''), @@ -237,7 +247,7 @@ class SchedulerRunService } } - private static function encodeResult(array $result): ?string + private function encodeResult(array $result): ?string { if (!$result) { return null; @@ -246,7 +256,7 @@ class SchedulerRunService return is_string($encoded) ? $encoded : null; } - private static function parseUtc(string $value): ?\DateTimeImmutable + private function parseUtc(string $value): ?\DateTimeImmutable { $value = trim($value); if ($value === '') { @@ -259,28 +269,28 @@ class SchedulerRunService } } - private static function nullableString(mixed $value): ?string + private function nullableString(mixed $value): ?string { $value = trim((string) $value); return $value !== '' ? $value : null; } - private static function acquireRunnerLock(): bool + private function acquireRunnerLock(): bool { $got = DB::selectValue('select GET_LOCK(?, 0) as got_lock', self::RUNNER_LOCK_NAME); return (int) $got === 1; } - private static function updateRuntimeHeartbeat(string $status, ?string $errorCode): void + private function updateRuntimeHeartbeat(string $status, ?string $errorCode): void { try { - SchedulerRuntimeRepository::touchHeartbeat($status, $errorCode); + $this->schedulerRuntimeRepository->touchHeartbeat($status, $errorCode); } catch (\Throwable $exception) { // fail-open } } - private static function releaseRunnerLock(): void + private function releaseRunnerLock(): void { try { DB::selectValue('select RELEASE_LOCK(?) as released_lock', self::RUNNER_LOCK_NAME); @@ -289,7 +299,7 @@ class SchedulerRunService } } - private static function durationMs(float $startedAt): int + private function durationMs(float $startedAt): int { return (int) max(0, round((microtime(true) - $startedAt) * 1000)); } diff --git a/lib/Service/Scheduler/SchedulerServicesFactory.php b/lib/Service/Scheduler/SchedulerServicesFactory.php new file mode 100644 index 0000000..757099c --- /dev/null +++ b/lib/Service/Scheduler/SchedulerServicesFactory.php @@ -0,0 +1,83 @@ +scheduledJobService ??= new ScheduledJobService( + $this->getScheduledJobRepository(), + $this->getScheduledJobRunRepository(), + $this->getScheduledJobRegistry(), + $this->getScheduleCalculator() + ); + } + + public function createSchedulerRunService(): SchedulerRunService + { + return $this->schedulerRunService ??= new SchedulerRunService( + $this->createScheduledJobService(), + $this->getScheduledJobRepository(), + $this->getScheduledJobRunRepository(), + $this->getSchedulerRuntimeRepository(), + $this->getScheduledJobRegistry(), + $this->getScheduleCalculator() + ); + } + + public function createUserLifecycleService(): UserLifecycleService + { + return $this->getUserLifecycleService(); + } + + private function getScheduledJobRepository(): ScheduledJobRepository + { + return $this->scheduledJobRepository ??= new ScheduledJobRepository(); + } + + private function getScheduledJobRunRepository(): ScheduledJobRunRepository + { + return $this->scheduledJobRunRepository ??= new ScheduledJobRunRepository(); + } + + private function getSchedulerRuntimeRepository(): SchedulerRuntimeRepository + { + return $this->schedulerRuntimeRepository ??= new SchedulerRuntimeRepository(); + } + + private function getScheduleCalculator(): ScheduleCalculator + { + return $this->scheduleCalculator ??= new ScheduleCalculator(); + } + + private function getUserLifecycleService(): UserLifecycleService + { + return $this->getUserServicesFactory()->createUserLifecycleService(); + } + + private function getScheduledJobRegistry(): ScheduledJobRegistry + { + return $this->scheduledJobRegistry ??= new ScheduledJobRegistry($this->getUserLifecycleService()); + } + + private function getUserServicesFactory(): UserServicesFactory + { + return $this->userServicesFactory ??= new UserServicesFactory(); + } +} diff --git a/lib/Service/Security/RateLimiterService.php b/lib/Service/Security/RateLimiterService.php index f828565..0622092 100644 --- a/lib/Service/Security/RateLimiterService.php +++ b/lib/Service/Security/RateLimiterService.php @@ -10,37 +10,41 @@ class RateLimiterService { private const HASH_ALGO = 'sha256'; - public static function hit(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array + public function __construct(private readonly RateLimitRepository $rateLimitRepository) { - return self::apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true); } - public static function registerFailure(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array + public function hit(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array { - return self::apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true); + return $this->apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true); } - public static function isBlocked(string $scope, string $subject): array + public function registerFailure(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array { - $normalized = self::normalize($scope, $subject); + return $this->apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true); + } + + public function isBlocked(string $scope, string $subject): array + { + $normalized = $this->normalize($scope, $subject); if ($normalized === null) { return ['allowed' => true, 'retry_after' => 0]; } try { - $row = RateLimitRepository::findByScopeAndHash($normalized['scope'], $normalized['subject_hash']); + $row = $this->rateLimitRepository->findByScopeAndHash($normalized['scope'], $normalized['subject_hash']); if (!is_array($row)) { return ['allowed' => true, 'retry_after' => 0]; } - $blockedUntilTs = self::parseTimestamp((string) ($row['blocked_until'] ?? '')); + $blockedUntilTs = $this->parseTimestamp((string) ($row['blocked_until'] ?? '')); if ($blockedUntilTs === null) { return ['allowed' => true, 'retry_after' => 0]; } $nowTs = time(); if ($blockedUntilTs <= $nowTs) { - RateLimitRepository::updateStateById( + $this->rateLimitRepository->updateStateById( (int) ($row['id'] ?? 0), 0, gmdate('Y-m-d H:i:s', $nowTs), @@ -59,21 +63,21 @@ class RateLimiterService } } - public static function reset(string $scope, string $subject): void + public function reset(string $scope, string $subject): void { - $normalized = self::normalize($scope, $subject); + $normalized = $this->normalize($scope, $subject); if ($normalized === null) { return; } try { - RateLimitRepository::deleteByScopeAndHash($normalized['scope'], $normalized['subject_hash']); + $this->rateLimitRepository->deleteByScopeAndHash($normalized['scope'], $normalized['subject_hash']); } catch (\Throwable $exception) { // Ignore reset failures. } } - private static function apply( + private function apply( string $scope, string $subject, int $maxHits, @@ -81,7 +85,7 @@ class RateLimiterService int $blockSeconds, bool $increment ): array { - $normalized = self::normalize($scope, $subject); + $normalized = $this->normalize($scope, $subject); if ($normalized === null) { return ['allowed' => true, 'retry_after' => 0]; } @@ -95,7 +99,7 @@ class RateLimiterService $nowSql = gmdate('Y-m-d H:i:s', $nowTs); for ($attempt = 0; $attempt < 2; $attempt++) { - $row = RateLimitRepository::findByScopeAndHash($normalized['scope'], $normalized['subject_hash']); + $row = $this->rateLimitRepository->findByScopeAndHash($normalized['scope'], $normalized['subject_hash']); if (!is_array($row)) { if (!$increment) { @@ -103,26 +107,15 @@ class RateLimiterService } $hits = 1; - $blockedUntilTs = null; - if ($hits > $maxHits) { - $blockedUntilTs = $nowTs + $blockSeconds; - } - - $created = RateLimitRepository::create( + $created = $this->rateLimitRepository->create( $normalized['scope'], $normalized['subject_hash'], $hits, $nowSql, - $blockedUntilTs !== null ? gmdate('Y-m-d H:i:s', $blockedUntilTs) : null + null ); if ($created) { - if ($blockedUntilTs !== null) { - return [ - 'allowed' => false, - 'retry_after' => max(1, $blockedUntilTs - $nowTs), - ]; - } return ['allowed' => true, 'retry_after' => 0]; } @@ -134,7 +127,7 @@ class RateLimiterService return ['allowed' => true, 'retry_after' => 0]; } - $blockedUntilTs = self::parseTimestamp((string) ($row['blocked_until'] ?? '')); + $blockedUntilTs = $this->parseTimestamp((string) ($row['blocked_until'] ?? '')); if ($blockedUntilTs !== null && $blockedUntilTs > $nowTs) { return [ 'allowed' => false, @@ -142,7 +135,7 @@ class RateLimiterService ]; } - $windowStartedTs = self::parseTimestamp((string) ($row['window_started_at'] ?? '')); + $windowStartedTs = $this->parseTimestamp((string) ($row['window_started_at'] ?? '')); if ($windowStartedTs === null || ($windowStartedTs + $windowSeconds) <= $nowTs) { $windowStartedTs = $nowTs; $hits = 0; @@ -159,7 +152,7 @@ class RateLimiterService $newBlockedUntilTs = $nowTs + $blockSeconds; } - RateLimitRepository::updateStateById( + $this->rateLimitRepository->updateStateById( $id, $hits, gmdate('Y-m-d H:i:s', $windowStartedTs), @@ -183,7 +176,7 @@ class RateLimiterService } } - private static function normalize(string $scope, string $subject): ?array + private function normalize(string $scope, string $subject): ?array { $scope = strtolower(trim($scope)); $subject = trim($subject); @@ -202,7 +195,7 @@ class RateLimiterService ]; } - private static function parseTimestamp(string $value): ?int + private function parseTimestamp(string $value): ?int { $value = trim($value); if ($value === '') { diff --git a/lib/Service/Security/SecurityServicesFactory.php b/lib/Service/Security/SecurityServicesFactory.php new file mode 100644 index 0000000..d612038 --- /dev/null +++ b/lib/Service/Security/SecurityServicesFactory.php @@ -0,0 +1,21 @@ +rateLimitRepository ??= new RateLimitRepository(); + } + + public function createRateLimiterService(): RateLimiterService + { + return $this->rateLimiterService ??= new RateLimiterService($this->createRateLimitRepository()); + } +} diff --git a/lib/Service/Settings/SettingCacheService.php b/lib/Service/Settings/SettingCacheService.php index 6b33b2b..90b74cf 100644 --- a/lib/Service/Settings/SettingCacheService.php +++ b/lib/Service/Settings/SettingCacheService.php @@ -4,11 +4,17 @@ namespace MintyPHP\Service\Settings; class SettingCacheService { - private static ?array $settings = null; + private ?array $settings = null; + private ?string $cacheFilePath; - public static function get(string $key): ?string + public function __construct(?string $cacheFilePath = null) { - $settings = self::all(); + $this->cacheFilePath = $cacheFilePath; + } + + public function get(string $key): ?string + { + $settings = $this->all(); $value = $settings[$key] ?? null; if ($value === null) { return null; @@ -17,26 +23,26 @@ class SettingCacheService return $value !== '' ? $value : null; } - public static function all(): array + public function all(): array { - if (self::$settings !== null) { - return self::$settings; + if ($this->settings !== null) { + return $this->settings; } - $file = self::filePath(); + $file = $this->filePath(); if (!is_file($file)) { - self::$settings = []; - return self::$settings; + $this->settings = []; + return $this->settings; } $loaded = include $file; - self::$settings = is_array($loaded) ? $loaded : []; - return self::$settings; + $this->settings = is_array($loaded) ? $loaded : []; + return $this->settings; } - public static function update(array $values): bool + public function update(array $values): bool { - $current = self::all(); + $current = $this->all(); foreach ($values as $key => $value) { $settingKey = trim((string) $key); if ($settingKey === '') { @@ -44,12 +50,12 @@ class SettingCacheService } $current[$settingKey] = $value === null ? null : (string) $value; } - return self::write($current); + return $this->write($current); } - public static function write(array $settings): bool + public function write(array $settings): bool { - $file = self::filePath(); + $file = $this->filePath(); $dir = dirname($file); if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) { return false; @@ -60,7 +66,7 @@ class SettingCacheService return false; } - $payload = "exportValue($settings) . ";\n"; $written = @file_put_contents($tmp, $payload, LOCK_EX); if ($written === false) { @unlink($tmp); @@ -72,16 +78,19 @@ class SettingCacheService return false; } - self::$settings = $settings; + $this->settings = $settings; return true; } - public static function filePath(): string + public function filePath(): string { + if (is_string($this->cacheFilePath) && $this->cacheFilePath !== '') { + return $this->cacheFilePath; + } return dirname(__DIR__, 3) . '/config/settings.php'; } - private static function exportValue(mixed $value, int $depth = 0): string + private function exportValue(mixed $value, int $depth = 0): string { if (!is_array($value)) { return var_export($value, true); @@ -99,7 +108,7 @@ class SettingCacheService $lines[] = $childIndent . var_export($key, true) . ' => ' - . self::exportValue($item, $depth + 1) + . $this->exportValue($item, $depth + 1) . ','; } diff --git a/lib/Service/Settings/SettingGateway.php b/lib/Service/Settings/SettingGateway.php new file mode 100644 index 0000000..25d93c6 --- /dev/null +++ b/lib/Service/Settings/SettingGateway.php @@ -0,0 +1,265 @@ +settingService->get($key); + } + + public function getValue(string $key): ?string + { + return $this->settingService->getValue($key); + } + + public function getDefaultTenantId(): ?int + { + return $this->settingService->getDefaultTenantId(); + } + + public function setDefaultTenantId(?int $tenantId, ?string $description = null): bool + { + return $this->settingService->setDefaultTenantId($tenantId, $description); + } + + public function getDefaultRoleId(): ?int + { + return $this->settingService->getDefaultRoleId(); + } + + public function setDefaultRoleId(?int $roleId, ?string $description = null): bool + { + return $this->settingService->setDefaultRoleId($roleId, $description); + } + + public function getDefaultDepartmentId(): ?int + { + return $this->settingService->getDefaultDepartmentId(); + } + + public function setDefaultDepartmentId(?int $departmentId, ?string $description = null): bool + { + return $this->settingService->setDefaultDepartmentId($departmentId, $description); + } + + public function getAppTitle(): ?string + { + return $this->settingService->getAppTitle(); + } + + public function setAppTitle(?string $title, ?string $description = null): bool + { + return $this->settingService->setAppTitle($title, $description); + } + + public function getAppLocale(): ?string + { + return $this->settingService->getAppLocale(); + } + + public function setAppLocale(?string $locale, ?string $description = null): bool + { + return $this->settingService->setAppLocale($locale, $description); + } + + public function getAppTheme(): ?string + { + return $this->settingService->getAppTheme(); + } + + public function setAppTheme(?string $theme, ?string $description = null): bool + { + return $this->settingService->setAppTheme($theme, $description); + } + + public function isUserThemeAllowed(): bool + { + return $this->settingService->isUserThemeAllowed(); + } + + public function setUserThemeAllowed(bool $allowed, ?string $description = null): bool + { + return $this->settingService->setUserThemeAllowed($allowed, $description); + } + + public function isRegistrationEnabled(): bool + { + return $this->settingService->isRegistrationEnabled(); + } + + public function setRegistrationEnabled(bool $allowed, ?string $description = null): bool + { + return $this->settingService->setRegistrationEnabled($allowed, $description); + } + + public function getApiTokenDefaultTtlDays(): int + { + return $this->settingService->getApiTokenDefaultTtlDays(); + } + + public function setApiTokenDefaultTtlDays(?int $days, ?string $description = null): bool + { + return $this->settingService->setApiTokenDefaultTtlDays($days, $description); + } + + public function getApiTokenMaxTtlDays(): int + { + return $this->settingService->getApiTokenMaxTtlDays(); + } + + public function setApiTokenMaxTtlDays(?int $days, ?string $description = null): bool + { + return $this->settingService->setApiTokenMaxTtlDays($days, $description); + } + + public function getUserInactivityDeactivateDays(): int + { + return $this->settingService->getUserInactivityDeactivateDays(); + } + + public function setUserInactivityDeactivateDays(?int $days, ?string $description = null): bool + { + return $this->settingService->setUserInactivityDeactivateDays($days, $description); + } + + public function getUserInactivityDeleteDays(): int + { + return $this->settingService->getUserInactivityDeleteDays(); + } + + public function setUserInactivityDeleteDays(?int $days, ?string $description = null): bool + { + return $this->settingService->setUserInactivityDeleteDays($days, $description); + } + + public function getApiCorsAllowedOrigins(): array + { + return $this->settingService->getApiCorsAllowedOrigins(); + } + + public function getApiCorsAllowedOriginsText(): string + { + return $this->settingService->getApiCorsAllowedOriginsText(); + } + + public function setApiCorsAllowedOrigins(?string $rawOrigins, ?string $description = null): bool + { + return $this->settingService->setApiCorsAllowedOrigins($rawOrigins, $description); + } + + public function getMicrosoftSharedClientId(): ?string + { + return $this->settingService->getMicrosoftSharedClientId(); + } + + public function setMicrosoftSharedClientId(?string $clientId, ?string $description = null): bool + { + return $this->settingService->setMicrosoftSharedClientId($clientId, $description); + } + + public function getMicrosoftSharedClientSecret(): ?string + { + return $this->settingService->getMicrosoftSharedClientSecret(); + } + + public function setMicrosoftSharedClientSecret(?string $secret, ?string $description = null): bool + { + return $this->settingService->setMicrosoftSharedClientSecret($secret, $description); + } + + public function getMicrosoftAuthority(): string + { + return $this->settingService->getMicrosoftAuthority(); + } + + public function setMicrosoftAuthority(?string $authority, ?string $description = null): bool + { + return $this->settingService->setMicrosoftAuthority($authority, $description); + } + + public function getAppPrimaryColor(): ?string + { + return $this->settingService->getAppPrimaryColor(); + } + + public function setAppPrimaryColor(?string $color, ?string $description = null): bool + { + return $this->settingService->setAppPrimaryColor($color, $description); + } + + public function getSmtpHost(): ?string + { + return $this->settingService->getSmtpHost(); + } + + public function setSmtpHost(?string $host, ?string $description = null): bool + { + return $this->settingService->setSmtpHost($host, $description); + } + + public function getSmtpPort(): ?int + { + return $this->settingService->getSmtpPort(); + } + + public function setSmtpPort(?int $port, ?string $description = null): bool + { + return $this->settingService->setSmtpPort($port, $description); + } + + public function getSmtpUser(): ?string + { + return $this->settingService->getSmtpUser(); + } + + public function setSmtpUser(?string $user, ?string $description = null): bool + { + return $this->settingService->setSmtpUser($user, $description); + } + + public function getSmtpPassword(): ?string + { + return $this->settingService->getSmtpPassword(); + } + + public function setSmtpPassword(?string $password, ?string $description = null): bool + { + return $this->settingService->setSmtpPassword($password, $description); + } + + public function getSmtpSecure(): ?string + { + return $this->settingService->getSmtpSecure(); + } + + public function setSmtpSecure(?string $secure, ?string $description = null): bool + { + return $this->settingService->setSmtpSecure($secure, $description); + } + + public function getSmtpFrom(): ?string + { + return $this->settingService->getSmtpFrom(); + } + + public function setSmtpFrom(?string $from, ?string $description = null): bool + { + return $this->settingService->setSmtpFrom($from, $description); + } + + public function getSmtpFromName(): ?string + { + return $this->settingService->getSmtpFromName(); + } + + public function setSmtpFromName(?string $fromName, ?string $description = null): bool + { + return $this->settingService->setSmtpFromName($fromName, $description); + } +} diff --git a/lib/Service/Settings/SettingService.php b/lib/Service/Settings/SettingService.php index c310947..2ae5581 100644 --- a/lib/Service/Settings/SettingService.php +++ b/lib/Service/Settings/SettingService.php @@ -2,14 +2,22 @@ namespace MintyPHP\Service\Settings; -use MintyPHP\Repository\Settings\SettingRepository; -use MintyPHP\Repository\Tenant\TenantRepository; use MintyPHP\Repository\Access\RoleRepository; use MintyPHP\Repository\Org\DepartmentRepository; +use MintyPHP\Repository\Settings\SettingRepository; +use MintyPHP\Repository\Tenant\TenantRepository; use MintyPHP\Support\Crypto; class SettingService { + public function __construct( + private readonly SettingRepository $settingRepository, + private readonly TenantRepository $tenantRepository, + private readonly RoleRepository $roleRepository, + private readonly DepartmentRepository $departmentRepository, + private readonly ThemeConfigService $themeConfigService + ) { + } public const DEFAULT_TENANT_KEY = 'default_tenant_id'; public const DEFAULT_ROLE_KEY = 'default_role_id'; public const DEFAULT_DEPARTMENT_KEY = 'default_department_id'; @@ -43,112 +51,112 @@ class SettingService private const USER_INACTIVITY_DAYS_MIN = 0; private const USER_INACTIVITY_DAYS_MAX = 3650; - public static function get(string $key): ?array + public function get(string $key): ?array { - return SettingRepository::find($key); + return $this->settingRepository->find($key); } - public static function getValue(string $key): ?string + public function getValue(string $key): ?string { - return SettingRepository::getValue($key); + return $this->settingRepository->getValue($key); } - public static function getInt(string $key): ?int + public function getInt(string $key): ?int { - $value = SettingRepository::getValue($key); + $value = $this->settingRepository->getValue($key); if ($value === null || $value === '') { return null; } return (int) $value; } - public static function set(string $key, ?string $value, ?string $description = null): bool + public function set(string $key, ?string $value, ?string $description = null): bool { - return SettingRepository::set($key, $value, $description); + return $this->settingRepository->set($key, $value, $description); } - public static function getDefaultTenantId(): ?int + public function getDefaultTenantId(): ?int { - $id = self::getInt(self::DEFAULT_TENANT_KEY); + $id = $this->getInt(self::DEFAULT_TENANT_KEY); return $id && $id > 0 ? $id : null; } - public static function setDefaultTenantId(?int $tenantId, ?string $description = null): bool + public function setDefaultTenantId(?int $tenantId, ?string $description = null): bool { $value = $tenantId && $tenantId > 0 ? (string) $tenantId : null; if ($value !== null) { - $exists = TenantRepository::find($tenantId); + $exists = $this->tenantRepository->find($tenantId); if (!$exists) { return false; } } $desc = $description ?? 'setting.default_tenant'; - return SettingRepository::set(self::DEFAULT_TENANT_KEY, $value, $desc); + return $this->settingRepository->set(self::DEFAULT_TENANT_KEY, $value, $desc); } - public static function getDefaultRoleId(): ?int + public function getDefaultRoleId(): ?int { - $id = self::getInt(self::DEFAULT_ROLE_KEY); + $id = $this->getInt(self::DEFAULT_ROLE_KEY); return $id && $id > 0 ? $id : null; } - public static function setDefaultRoleId(?int $roleId, ?string $description = null): bool + public function setDefaultRoleId(?int $roleId, ?string $description = null): bool { $value = $roleId && $roleId > 0 ? (string) $roleId : null; if ($value !== null) { - $exists = RoleRepository::find($roleId); + $exists = $this->roleRepository->find($roleId); if (!$exists) { return false; } } $desc = $description ?? 'setting.default_role'; - return SettingRepository::set(self::DEFAULT_ROLE_KEY, $value, $desc); + return $this->settingRepository->set(self::DEFAULT_ROLE_KEY, $value, $desc); } - public static function getDefaultDepartmentId(): ?int + public function getDefaultDepartmentId(): ?int { - $id = self::getInt(self::DEFAULT_DEPARTMENT_KEY); + $id = $this->getInt(self::DEFAULT_DEPARTMENT_KEY); return $id && $id > 0 ? $id : null; } - public static function setDefaultDepartmentId(?int $departmentId, ?string $description = null): bool + public function setDefaultDepartmentId(?int $departmentId, ?string $description = null): bool { $value = $departmentId && $departmentId > 0 ? (string) $departmentId : null; if ($value !== null) { - $exists = DepartmentRepository::find($departmentId); + $exists = $this->departmentRepository->find($departmentId); if (!$exists) { return false; } } $desc = $description ?? 'setting.default_department'; - return SettingRepository::set(self::DEFAULT_DEPARTMENT_KEY, $value, $desc); + return $this->settingRepository->set(self::DEFAULT_DEPARTMENT_KEY, $value, $desc); } - public static function getAppTitle(): ?string + public function getAppTitle(): ?string { - $value = SettingRepository::getValue(self::APP_TITLE_KEY); + $value = $this->settingRepository->getValue(self::APP_TITLE_KEY); $value = $value !== null ? trim($value) : null; return $value !== '' ? $value : null; } - public static function setAppTitle(?string $title, ?string $description = null): bool + public function setAppTitle(?string $title, ?string $description = null): bool { $value = $title !== null ? trim($title) : null; if ($value === '') { $value = null; } $desc = $description ?? 'setting.app_title'; - return SettingRepository::set(self::APP_TITLE_KEY, $value, $desc); + return $this->settingRepository->set(self::APP_TITLE_KEY, $value, $desc); } - public static function getAppLocale(): ?string + public function getAppLocale(): ?string { - $value = SettingRepository::getValue(self::APP_LOCALE_KEY); + $value = $this->settingRepository->getValue(self::APP_LOCALE_KEY); $value = $value !== null ? trim((string) $value) : ''; return $value !== '' ? $value : null; } - public static function setAppLocale(?string $locale, ?string $description = null): bool + public function setAppLocale(?string $locale, ?string $description = null): bool { $value = $locale !== null ? trim((string) $locale) : ''; if ($value === '') { @@ -160,108 +168,108 @@ class SettingService } } $desc = $description ?? 'setting.app_locale'; - return SettingRepository::set(self::APP_LOCALE_KEY, $value, $desc); + return $this->settingRepository->set(self::APP_LOCALE_KEY, $value, $desc); } - public static function getAppTheme(): ?string + public function getAppTheme(): ?string { - $value = SettingRepository::getValue(self::APP_THEME_KEY); + $value = $this->settingRepository->getValue(self::APP_THEME_KEY); $value = $value !== null ? strtolower(trim((string) $value)) : ''; - if (!in_array($value, self::allowedThemes(), true)) { + if (!in_array($value, $this->allowedThemes(), true)) { return null; } return $value; } - public static function setAppTheme(?string $theme, ?string $description = null): bool + public function setAppTheme(?string $theme, ?string $description = null): bool { $value = $theme !== null ? strtolower(trim((string) $theme)) : ''; - if ($value === '' || !in_array($value, self::allowedThemes(), true)) { + if ($value === '' || !in_array($value, $this->allowedThemes(), true)) { $value = null; } $desc = $description ?? 'setting.app_theme'; - return SettingRepository::set(self::APP_THEME_KEY, $value, $desc); + return $this->settingRepository->set(self::APP_THEME_KEY, $value, $desc); } - public static function isUserThemeAllowed(): bool + public function isUserThemeAllowed(): bool { - $value = SettingRepository::getValue(self::APP_THEME_USER_KEY); + $value = $this->settingRepository->getValue(self::APP_THEME_USER_KEY); if ($value === null || $value === '') { return true; } return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); } - public static function setUserThemeAllowed(bool $allowed, ?string $description = null): bool + public function setUserThemeAllowed(bool $allowed, ?string $description = null): bool { $value = $allowed ? '1' : '0'; $desc = $description ?? 'setting.app_theme_user'; - return SettingRepository::set(self::APP_THEME_USER_KEY, $value, $desc); + return $this->settingRepository->set(self::APP_THEME_USER_KEY, $value, $desc); } - private static function allowedThemes(): array + private function allowedThemes(): array { - return array_keys(ThemeConfigService::all()); + return array_keys($this->themeConfigService->all()); } - public static function isRegistrationEnabled(): bool + public function isRegistrationEnabled(): bool { - $value = SettingRepository::getValue(self::APP_REGISTRATION_KEY); + $value = $this->settingRepository->getValue(self::APP_REGISTRATION_KEY); if ($value === null || $value === '') { return true; } return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); } - public static function setRegistrationEnabled(bool $allowed, ?string $description = null): bool + public function setRegistrationEnabled(bool $allowed, ?string $description = null): bool { $value = $allowed ? '1' : '0'; $desc = $description ?? 'setting.app_registration'; - return SettingRepository::set(self::APP_REGISTRATION_KEY, $value, $desc); + return $this->settingRepository->set(self::APP_REGISTRATION_KEY, $value, $desc); } - public static function getApiTokenDefaultTtlDays(): int + public function getApiTokenDefaultTtlDays(): int { - $maxDays = self::getApiTokenMaxTtlDays(); - $value = self::getInt(self::API_TOKEN_DEFAULT_TTL_DAYS_KEY); + $maxDays = $this->getApiTokenMaxTtlDays(); + $value = $this->getInt(self::API_TOKEN_DEFAULT_TTL_DAYS_KEY); if ($value !== null && $value >= self::API_TOKEN_TTL_DAYS_MIN && $value <= self::API_TOKEN_TTL_DAYS_HARD_MAX) { return min($value, $maxDays); } return min(self::API_TOKEN_DEFAULT_TTL_DAYS_FALLBACK, $maxDays); } - public static function setApiTokenDefaultTtlDays(?int $days, ?string $description = null): bool + public function setApiTokenDefaultTtlDays(?int $days, ?string $description = null): bool { $value = $days ?? self::API_TOKEN_DEFAULT_TTL_DAYS_FALLBACK; if ($value < self::API_TOKEN_TTL_DAYS_MIN || $value > self::API_TOKEN_TTL_DAYS_HARD_MAX) { return false; } - if ($value > self::getApiTokenMaxTtlDays()) { + if ($value > $this->getApiTokenMaxTtlDays()) { return false; } $desc = $description ?? 'setting.api_token_default_ttl_days'; - return SettingRepository::set(self::API_TOKEN_DEFAULT_TTL_DAYS_KEY, (string) $value, $desc); + return $this->settingRepository->set(self::API_TOKEN_DEFAULT_TTL_DAYS_KEY, (string) $value, $desc); } - public static function getApiTokenMaxTtlDays(): int + public function getApiTokenMaxTtlDays(): int { - $value = self::getInt(self::API_TOKEN_MAX_TTL_DAYS_KEY); + $value = $this->getInt(self::API_TOKEN_MAX_TTL_DAYS_KEY); if ($value !== null && $value >= self::API_TOKEN_TTL_DAYS_MIN && $value <= self::API_TOKEN_TTL_DAYS_HARD_MAX) { return $value; } return self::API_TOKEN_MAX_TTL_DAYS_FALLBACK; } - public static function setApiTokenMaxTtlDays(?int $days, ?string $description = null): bool + public function setApiTokenMaxTtlDays(?int $days, ?string $description = null): bool { $value = $days ?? self::API_TOKEN_MAX_TTL_DAYS_FALLBACK; if ($value < self::API_TOKEN_TTL_DAYS_MIN || $value > self::API_TOKEN_TTL_DAYS_HARD_MAX) { return false; } - $currentDefault = self::getInt(self::API_TOKEN_DEFAULT_TTL_DAYS_KEY); + $currentDefault = $this->getInt(self::API_TOKEN_DEFAULT_TTL_DAYS_KEY); if ($currentDefault !== null && $currentDefault > $value) { - $defaultUpdated = SettingRepository::set( + $defaultUpdated = $this->settingRepository->set( self::API_TOKEN_DEFAULT_TTL_DAYS_KEY, (string) $value, 'setting.api_token_default_ttl_days' @@ -272,26 +280,26 @@ class SettingService } $desc = $description ?? 'setting.api_token_max_ttl_days'; - return SettingRepository::set(self::API_TOKEN_MAX_TTL_DAYS_KEY, (string) $value, $desc); + return $this->settingRepository->set(self::API_TOKEN_MAX_TTL_DAYS_KEY, (string) $value, $desc); } - public static function getUserInactivityDeactivateDays(): int + public function getUserInactivityDeactivateDays(): int { - $value = self::getInt(self::USER_INACTIVITY_DEACTIVATE_DAYS_KEY); - if ($value !== null && self::isValidInactivityDays($value)) { + $value = $this->getInt(self::USER_INACTIVITY_DEACTIVATE_DAYS_KEY); + if ($value !== null && $this->isValidInactivityDays($value)) { return $value; } return self::USER_INACTIVITY_DEACTIVATE_DAYS_FALLBACK; } - public static function setUserInactivityDeactivateDays(?int $days, ?string $description = null): bool + public function setUserInactivityDeactivateDays(?int $days, ?string $description = null): bool { $value = $days ?? self::USER_INACTIVITY_DEACTIVATE_DAYS_FALLBACK; - if (!self::isValidInactivityDays($value)) { + if (!$this->isValidInactivityDays($value)) { return false; } if ($value === 0) { - $deleteUpdated = SettingRepository::set( + $deleteUpdated = $this->settingRepository->set( self::USER_INACTIVITY_DELETE_DAYS_KEY, '0', 'setting.user_inactivity_delete_days' @@ -301,79 +309,79 @@ class SettingService } } $desc = $description ?? 'setting.user_inactivity_deactivate_days'; - return SettingRepository::set(self::USER_INACTIVITY_DEACTIVATE_DAYS_KEY, (string) $value, $desc); + return $this->settingRepository->set(self::USER_INACTIVITY_DEACTIVATE_DAYS_KEY, (string) $value, $desc); } - public static function getUserInactivityDeleteDays(): int + public function getUserInactivityDeleteDays(): int { - $deactivateDays = self::getUserInactivityDeactivateDays(); + $deactivateDays = $this->getUserInactivityDeactivateDays(); if ($deactivateDays <= 0) { return 0; } - $value = self::getInt(self::USER_INACTIVITY_DELETE_DAYS_KEY); - if ($value !== null && self::isValidInactivityDays($value)) { + $value = $this->getInt(self::USER_INACTIVITY_DELETE_DAYS_KEY); + if ($value !== null && $this->isValidInactivityDays($value)) { return $value; } return self::USER_INACTIVITY_DELETE_DAYS_FALLBACK; } - public static function setUserInactivityDeleteDays(?int $days, ?string $description = null): bool + public function setUserInactivityDeleteDays(?int $days, ?string $description = null): bool { $value = $days ?? self::USER_INACTIVITY_DELETE_DAYS_FALLBACK; - if (!self::isValidInactivityDays($value)) { + if (!$this->isValidInactivityDays($value)) { return false; } - if ($value > 0 && self::getUserInactivityDeactivateDays() <= 0) { + if ($value > 0 && $this->getUserInactivityDeactivateDays() <= 0) { return false; } $desc = $description ?? 'setting.user_inactivity_delete_days'; - return SettingRepository::set(self::USER_INACTIVITY_DELETE_DAYS_KEY, (string) $value, $desc); + return $this->settingRepository->set(self::USER_INACTIVITY_DELETE_DAYS_KEY, (string) $value, $desc); } - public static function getApiCorsAllowedOrigins(): array + public function getApiCorsAllowedOrigins(): array { - $stored = SettingRepository::getValue(self::API_CORS_ALLOWED_ORIGINS_KEY); + $stored = $this->settingRepository->getValue(self::API_CORS_ALLOWED_ORIGINS_KEY); if ($stored === null || trim($stored) === '') { return []; } - return self::parseCorsOrigins($stored, false) ?? []; + return $this->parseCorsOrigins($stored, false) ?? []; } - public static function getApiCorsAllowedOriginsText(): string + public function getApiCorsAllowedOriginsText(): string { - $origins = self::getApiCorsAllowedOrigins(); + $origins = $this->getApiCorsAllowedOrigins(); return implode("\n", $origins); } - public static function setApiCorsAllowedOrigins(?string $rawOrigins, ?string $description = null): bool + public function setApiCorsAllowedOrigins(?string $rawOrigins, ?string $description = null): bool { $value = (string) ($rawOrigins ?? ''); if (trim($value) === '') { - return SettingRepository::set( + return $this->settingRepository->set( self::API_CORS_ALLOWED_ORIGINS_KEY, null, $description ?? 'setting.api_cors_allowed_origins' ); } - $origins = self::parseCorsOrigins($value, true); + $origins = $this->parseCorsOrigins($value, true); if ($origins === null) { return false; } - return SettingRepository::set( + return $this->settingRepository->set( self::API_CORS_ALLOWED_ORIGINS_KEY, implode("\n", $origins), $description ?? 'setting.api_cors_allowed_origins' ); } - private static function parseCorsOrigins(string $rawOrigins, bool $strict): ?array + private function parseCorsOrigins(string $rawOrigins, bool $strict): ?array { $parts = preg_split('/[\r\n,]+/', $rawOrigins) ?: []; $normalizedOrigins = []; foreach ($parts as $part) { - $origin = self::normalizeCorsOrigin($part); + $origin = $this->normalizeCorsOrigin($part); if ($origin === null) { if ($strict && trim((string) $part) !== '') { return null; @@ -385,7 +393,7 @@ class SettingService return array_keys($normalizedOrigins); } - private static function normalizeCorsOrigin(string $origin): ?string + private function normalizeCorsOrigin(string $origin): ?string { $origin = trim($origin); if ($origin === '') { @@ -426,33 +434,33 @@ class SettingService return $scheme . '://' . $host . $portSuffix; } - public static function getMicrosoftSharedClientId(): ?string + public function getMicrosoftSharedClientId(): ?string { - $value = SettingRepository::getValue(self::MICROSOFT_SHARED_CLIENT_ID_KEY); + $value = $this->settingRepository->getValue(self::MICROSOFT_SHARED_CLIENT_ID_KEY); $value = $value !== null ? trim((string) $value) : ''; return $value !== '' ? $value : null; } - public static function setMicrosoftSharedClientId(?string $clientId, ?string $description = null): bool + public function setMicrosoftSharedClientId(?string $clientId, ?string $description = null): bool { $value = $clientId !== null ? trim((string) $clientId) : ''; if ($value === '') { $value = null; } $desc = $description ?? 'setting.microsoft_shared_client_id'; - return SettingRepository::set(self::MICROSOFT_SHARED_CLIENT_ID_KEY, $value, $desc); + return $this->settingRepository->set(self::MICROSOFT_SHARED_CLIENT_ID_KEY, $value, $desc); } - public static function getMicrosoftSharedClientSecretEncrypted(): ?string + public function getMicrosoftSharedClientSecretEncrypted(): ?string { - $value = SettingRepository::getValue(self::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY); + $value = $this->settingRepository->getValue(self::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY); $value = $value !== null ? trim((string) $value) : ''; return $value !== '' ? $value : null; } - public static function getMicrosoftSharedClientSecret(): ?string + public function getMicrosoftSharedClientSecret(): ?string { - $encrypted = self::getMicrosoftSharedClientSecretEncrypted(); + $encrypted = $this->getMicrosoftSharedClientSecretEncrypted(); if ($encrypted === null) { return null; } @@ -465,11 +473,11 @@ class SettingService return $decrypted !== '' ? $decrypted : null; } - public static function setMicrosoftSharedClientSecret(?string $secret, ?string $description = null): bool + public function setMicrosoftSharedClientSecret(?string $secret, ?string $description = null): bool { $value = $secret !== null ? trim((string) $secret) : ''; if ($value === '') { - return SettingRepository::set( + return $this->settingRepository->set( self::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY, null, $description ?? 'setting.microsoft_shared_client_secret_enc' @@ -477,12 +485,12 @@ class SettingService } $encrypted = Crypto::encryptString($value); $desc = $description ?? 'setting.microsoft_shared_client_secret_enc'; - return SettingRepository::set(self::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY, $encrypted, $desc); + return $this->settingRepository->set(self::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY, $encrypted, $desc); } - public static function getMicrosoftAuthority(): string + public function getMicrosoftAuthority(): string { - $value = SettingRepository::getValue(self::MICROSOFT_AUTHORITY_KEY); + $value = $this->settingRepository->getValue(self::MICROSOFT_AUTHORITY_KEY); $value = trim((string) ($value ?? '')); if ($value === '') { return 'https://login.microsoftonline.com/common/v2.0'; @@ -490,7 +498,7 @@ class SettingService return rtrim($value, '/'); } - public static function setMicrosoftAuthority(?string $authority, ?string $description = null): bool + public function setMicrosoftAuthority(?string $authority, ?string $description = null): bool { $value = trim((string) ($authority ?? '')); if ($value === '') { @@ -500,12 +508,12 @@ class SettingService return false; } $desc = $description ?? 'setting.microsoft_authority'; - return SettingRepository::set(self::MICROSOFT_AUTHORITY_KEY, rtrim($value, '/'), $desc); + return $this->settingRepository->set(self::MICROSOFT_AUTHORITY_KEY, rtrim($value, '/'), $desc); } - public static function getAppPrimaryColor(): ?string + public function getAppPrimaryColor(): ?string { - $value = SettingRepository::getValue(self::APP_PRIMARY_COLOR_KEY); + $value = $this->settingRepository->getValue(self::APP_PRIMARY_COLOR_KEY); $value = $value !== null ? strtolower(trim((string) $value)) : ''; if ($value === '') { return null; @@ -516,7 +524,7 @@ class SettingService return $value; } - public static function setAppPrimaryColor(?string $color, ?string $description = null): bool + public function setAppPrimaryColor(?string $color, ?string $description = null): bool { $value = $color !== null ? strtolower(trim((string) $color)) : ''; if ($value !== '' && $value[0] !== '#') { @@ -533,29 +541,29 @@ class SettingService return false; } $desc = $description ?? 'setting.app_primary_color'; - return SettingRepository::set(self::APP_PRIMARY_COLOR_KEY, $value, $desc); + return $this->settingRepository->set(self::APP_PRIMARY_COLOR_KEY, $value, $desc); } - public static function getSmtpHost(): ?string + public function getSmtpHost(): ?string { - $value = SettingRepository::getValue(self::SMTP_HOST_KEY); + $value = $this->settingRepository->getValue(self::SMTP_HOST_KEY); $value = $value !== null ? trim((string) $value) : ''; return $value !== '' ? $value : null; } - public static function setSmtpHost(?string $host, ?string $description = null): bool + public function setSmtpHost(?string $host, ?string $description = null): bool { $value = $host !== null ? trim((string) $host) : ''; if ($value === '') { $value = null; } $desc = $description ?? 'setting.smtp_host'; - return SettingRepository::set(self::SMTP_HOST_KEY, $value, $desc); + return $this->settingRepository->set(self::SMTP_HOST_KEY, $value, $desc); } - public static function getSmtpPort(): ?int + public function getSmtpPort(): ?int { - $value = SettingRepository::getValue(self::SMTP_PORT_KEY); + $value = $this->settingRepository->getValue(self::SMTP_PORT_KEY); if ($value === null || $value === '') { return null; } @@ -563,50 +571,50 @@ class SettingService return $port > 0 ? $port : null; } - public static function setSmtpPort(?int $port, ?string $description = null): bool + public function setSmtpPort(?int $port, ?string $description = null): bool { $value = $port && $port > 0 ? (string) $port : null; $desc = $description ?? 'setting.smtp_port'; - return SettingRepository::set(self::SMTP_PORT_KEY, $value, $desc); + return $this->settingRepository->set(self::SMTP_PORT_KEY, $value, $desc); } - public static function getSmtpUser(): ?string + public function getSmtpUser(): ?string { - $value = SettingRepository::getValue(self::SMTP_USER_KEY); + $value = $this->settingRepository->getValue(self::SMTP_USER_KEY); $value = $value !== null ? trim((string) $value) : ''; return $value !== '' ? $value : null; } - public static function setSmtpUser(?string $user, ?string $description = null): bool + public function setSmtpUser(?string $user, ?string $description = null): bool { $value = $user !== null ? trim((string) $user) : ''; if ($value === '') { $value = null; } $desc = $description ?? 'setting.smtp_user'; - return SettingRepository::set(self::SMTP_USER_KEY, $value, $desc); + return $this->settingRepository->set(self::SMTP_USER_KEY, $value, $desc); } - public static function getSmtpPassword(): ?string + public function getSmtpPassword(): ?string { - $value = SettingRepository::getValue(self::SMTP_PASSWORD_KEY); + $value = $this->settingRepository->getValue(self::SMTP_PASSWORD_KEY); $value = $value !== null ? (string) $value : ''; return $value !== '' ? $value : null; } - public static function setSmtpPassword(?string $password, ?string $description = null): bool + public function setSmtpPassword(?string $password, ?string $description = null): bool { $value = $password !== null ? (string) $password : ''; if ($value === '') { $value = null; } $desc = $description ?? 'setting.smtp_password'; - return SettingRepository::set(self::SMTP_PASSWORD_KEY, $value, $desc); + return $this->settingRepository->set(self::SMTP_PASSWORD_KEY, $value, $desc); } - public static function getSmtpSecure(): ?string + public function getSmtpSecure(): ?string { - $value = SettingRepository::getValue(self::SMTP_SECURE_KEY); + $value = $this->settingRepository->getValue(self::SMTP_SECURE_KEY); $value = $value !== null ? strtolower(trim((string) $value)) : ''; if (!in_array($value, ['tls', 'ssl', 'none'], true)) { return null; @@ -614,7 +622,7 @@ class SettingService return $value === 'none' ? null : $value; } - public static function setSmtpSecure(?string $secure, ?string $description = null): bool + public function setSmtpSecure(?string $secure, ?string $description = null): bool { $value = $secure !== null ? strtolower(trim((string) $secure)) : ''; if ($value === '' || $value === 'none') { @@ -623,44 +631,44 @@ class SettingService return false; } $desc = $description ?? 'setting.smtp_secure'; - return SettingRepository::set(self::SMTP_SECURE_KEY, $value, $desc); + return $this->settingRepository->set(self::SMTP_SECURE_KEY, $value, $desc); } - public static function getSmtpFrom(): ?string + public function getSmtpFrom(): ?string { - $value = SettingRepository::getValue(self::SMTP_FROM_KEY); + $value = $this->settingRepository->getValue(self::SMTP_FROM_KEY); $value = $value !== null ? trim((string) $value) : ''; return $value !== '' ? $value : null; } - public static function setSmtpFrom(?string $from, ?string $description = null): bool + public function setSmtpFrom(?string $from, ?string $description = null): bool { $value = $from !== null ? trim((string) $from) : ''; if ($value === '') { $value = null; } $desc = $description ?? 'setting.smtp_from'; - return SettingRepository::set(self::SMTP_FROM_KEY, $value, $desc); + return $this->settingRepository->set(self::SMTP_FROM_KEY, $value, $desc); } - public static function getSmtpFromName(): ?string + public function getSmtpFromName(): ?string { - $value = SettingRepository::getValue(self::SMTP_FROM_NAME_KEY); + $value = $this->settingRepository->getValue(self::SMTP_FROM_NAME_KEY); $value = $value !== null ? trim((string) $value) : ''; return $value !== '' ? $value : null; } - public static function setSmtpFromName(?string $fromName, ?string $description = null): bool + public function setSmtpFromName(?string $fromName, ?string $description = null): bool { $value = $fromName !== null ? trim((string) $fromName) : ''; if ($value === '') { $value = null; } $desc = $description ?? 'setting.smtp_from_name'; - return SettingRepository::set(self::SMTP_FROM_NAME_KEY, $value, $desc); + return $this->settingRepository->set(self::SMTP_FROM_NAME_KEY, $value, $desc); } - private static function isValidInactivityDays(int $days): bool + private function isValidInactivityDays(int $days): bool { return $days >= self::USER_INACTIVITY_DAYS_MIN && $days <= self::USER_INACTIVITY_DAYS_MAX; } diff --git a/lib/Service/Settings/SettingServicesFactory.php b/lib/Service/Settings/SettingServicesFactory.php new file mode 100644 index 0000000..046c44d --- /dev/null +++ b/lib/Service/Settings/SettingServicesFactory.php @@ -0,0 +1,66 @@ +settingRepository ??= new SettingRepository(); + } + + public function createSettingService(): SettingService + { + return $this->settingService ??= new SettingService( + $this->createSettingRepository(), + $this->createTenantRepository(), + $this->createRoleRepository(), + $this->createDepartmentRepository(), + $this->createThemeConfigService() + ); + } + + public function createSettingGateway(): SettingGateway + { + return $this->settingGateway ??= new SettingGateway($this->createSettingService()); + } + + public function createSettingCacheService(): SettingCacheService + { + return $this->settingCacheService ??= new SettingCacheService(); + } + + public function createThemeConfigService(): ThemeConfigService + { + return $this->themeConfigService ??= new ThemeConfigService(); + } + + private function createTenantRepository(): TenantRepository + { + return $this->tenantRepository ??= new TenantRepository(); + } + + private function createRoleRepository(): RoleRepository + { + return $this->roleRepository ??= new RoleRepository(); + } + + private function createDepartmentRepository(): DepartmentRepository + { + return $this->departmentRepository ??= new DepartmentRepository(); + } +} diff --git a/lib/Service/Settings/ThemeConfigService.php b/lib/Service/Settings/ThemeConfigService.php index 562c9ef..7757ef3 100644 --- a/lib/Service/Settings/ThemeConfigService.php +++ b/lib/Service/Settings/ThemeConfigService.php @@ -9,24 +9,24 @@ class ThemeConfigService 'dark' => 'Dark', ]; - private static ?array $themes = null; + private ?array $themes = null; - public static function all(): array + public function all(): array { - if (self::$themes !== null) { - return self::$themes; + if ($this->themes !== null) { + return $this->themes; } $file = dirname(__DIR__, 3) . '/config/themes.php'; if (!is_file($file)) { - self::$themes = self::DEFAULT_THEMES; - return self::$themes; + $this->themes = self::DEFAULT_THEMES; + return $this->themes; } $loaded = include $file; if (!is_array($loaded)) { - self::$themes = self::DEFAULT_THEMES; - return self::$themes; + $this->themes = self::DEFAULT_THEMES; + return $this->themes; } $clean = []; @@ -39,7 +39,7 @@ class ThemeConfigService $clean[$themeKey] = $themeLabel; } - self::$themes = $clean ?: self::DEFAULT_THEMES; - return self::$themes; + $this->themes = $clean ?: self::DEFAULT_THEMES; + return $this->themes; } } diff --git a/lib/Service/Tenant/TenantAvatarService.php b/lib/Service/Tenant/TenantAvatarService.php index 0247fd9..3dd4f08 100644 --- a/lib/Service/Tenant/TenantAvatarService.php +++ b/lib/Service/Tenant/TenantAvatarService.php @@ -12,39 +12,39 @@ class TenantAvatarService private const SIZES = [64, 128, 256]; private const DEFAULT_SIZE = 128; - public static function isValidUuid(string $uuid): bool + public function isValidUuid(string $uuid): bool { return self::imageIsValidUuid($uuid); } - public static function storageBase(): string + public function storageBase(): string { return self::imageStorageBase(); } - public static function tenantDir(string $uuid): string + public function tenantDir(string $uuid): string { - return self::storageBase() . '/tenants/' . $uuid . '/avatar'; + return $this->storageBase() . '/tenants/' . $uuid . '/avatar'; } - public static function findAvatarPath(string $uuid, ?int $size = null): ?string + public function findAvatarPath(string $uuid, ?int $size = null): ?string { - if (!self::isValidUuid($uuid)) { + if (!$this->isValidUuid($uuid)) { return null; } - $dirs = self::avatarDirs($uuid); + $dirs = $this->avatarDirs($uuid); foreach ($dirs as $dir) { if (!is_dir($dir)) { continue; } if ($size) { - $size = self::normalizeSize($size); - $variant = self::findVariantPath($dir, $size); + $size = $this->normalizeSize($size); + $variant = $this->findVariantPath($dir, $size); if ($variant) { return $variant; } } - $defaultVariant = self::findVariantPath($dir, self::DEFAULT_SIZE); + $defaultVariant = $this->findVariantPath($dir, self::DEFAULT_SIZE); if ($defaultVariant) { return $defaultVariant; } @@ -56,18 +56,18 @@ class TenantAvatarService return null; } - public static function hasAvatar(string $uuid): bool + public function hasAvatar(string $uuid): bool { - $path = self::findAvatarPath($uuid); + $path = $this->findAvatarPath($uuid); return $path ? is_file($path) : false; } - public static function delete(string $uuid): bool + public function delete(string $uuid): bool { - if (!self::isValidUuid($uuid)) { + if (!$this->isValidUuid($uuid)) { return false; } - foreach (self::avatarDirs($uuid) as $dir) { + foreach ($this->avatarDirs($uuid) as $dir) { if (!is_dir($dir)) { continue; } @@ -85,9 +85,9 @@ class TenantAvatarService return true; } - public static function saveUpload(string $uuid, array $file): array + public function saveUpload(string $uuid, array $file): array { - if (!self::isValidUuid($uuid)) { + if (!$this->isValidUuid($uuid)) { return ['ok' => false, 'error' => t('Tenant not found')]; } if (empty($file) || !isset($file['tmp_name'])) { @@ -101,7 +101,7 @@ class TenantAvatarService } $tmpPath = $file['tmp_name']; - $mime = self::detectMime($tmpPath); + $mime = $this->detectMime($tmpPath); $isSvg = self::imageIsSvgUpload($mime, $tmpPath); $ext = self::imageExtensionForMime($mime, $isSvg); if (!$ext) { @@ -111,12 +111,12 @@ class TenantAvatarService return ['ok' => false, 'error' => t('Invalid image file')]; } - $dir = self::tenantDir($uuid); + $dir = $this->tenantDir($uuid); if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { return ['ok' => false, 'error' => t('Upload failed')]; } - self::delete($uuid); + $this->delete($uuid); $originalPath = $dir . '/original.' . $ext; if (!move_uploaded_file($tmpPath, $originalPath)) { return ['ok' => false, 'error' => t('Upload failed')]; @@ -134,12 +134,12 @@ class TenantAvatarService return ['ok' => true, 'path' => $originalPath, 'mime' => $mime]; } - public static function detectMime(string $path): string + public function detectMime(string $path): string { return self::imageDetectMime($path); } - private static function normalizeSize(int $size): int + private function normalizeSize(int $size): int { if (in_array($size, self::SIZES, true)) { return $size; @@ -147,7 +147,7 @@ class TenantAvatarService return self::DEFAULT_SIZE; } - private static function findVariantPath(string $dir, int $size): ?string + private function findVariantPath(string $dir, int $size): ?string { $matches = glob($dir . '/avatar-' . $size . '.*'); if (!$matches) { @@ -159,18 +159,18 @@ class TenantAvatarService return $matches[0] ?? null; } - private static function avatarDirs(string $uuid): array + private function avatarDirs(string $uuid): array { - $dirs = [self::tenantDir($uuid)]; - $legacy = self::legacyTenantDir($uuid); + $dirs = [$this->tenantDir($uuid)]; + $legacy = $this->legacyTenantDir($uuid); if ($legacy !== $dirs[0]) { $dirs[] = $legacy; } return $dirs; } - private static function legacyTenantDir(string $uuid): string + private function legacyTenantDir(string $uuid): string { - return self::storageBase() . '/tenants/' . $uuid; + return $this->storageBase() . '/tenants/' . $uuid; } } diff --git a/lib/Service/Tenant/TenantFaviconService.php b/lib/Service/Tenant/TenantFaviconService.php index 6a8e875..eaf632e 100644 --- a/lib/Service/Tenant/TenantFaviconService.php +++ b/lib/Service/Tenant/TenantFaviconService.php @@ -18,41 +18,41 @@ class TenantFaviconService 512 => 'web-app-manifest-512x512.png', ]; - public static function isValidUuid(string $uuid): bool + public function isValidUuid(string $uuid): bool { return self::imageIsValidUuid($uuid); } - public static function storageBase(): string + public function storageBase(): string { return self::imageStorageBase(); } - public static function storageDir(string $uuid): string + public function storageDir(string $uuid): string { - return self::storageBase() . '/tenants/' . $uuid . '/favicon'; + return $this->storageBase() . '/tenants/' . $uuid . '/favicon'; } - public static function publicDir(string $uuid): string + public function publicDir(string $uuid): string { return rtrim(dirname(__DIR__, 3) . '/web/favicon/tenants/' . $uuid . '/favicon', '/'); } - public static function hasFavicon(string $uuid): bool + public function hasFavicon(string $uuid): bool { - if (!self::isValidUuid($uuid)) { + if (!$this->isValidUuid($uuid)) { return false; } - $path = self::publicDir($uuid) . '/favicon-32x32.png'; + $path = $this->publicDir($uuid) . '/favicon-32x32.png'; return is_file($path); } - public static function delete(string $uuid): void + public function delete(string $uuid): void { - if (!self::isValidUuid($uuid)) { + if (!$this->isValidUuid($uuid)) { return; } - foreach (self::storageDirs($uuid) as $dir) { + foreach ($this->storageDirs($uuid) as $dir) { if (!is_dir($dir)) { continue; } @@ -63,7 +63,7 @@ class TenantFaviconService } } } - $public = self::publicDir($uuid); + $public = $this->publicDir($uuid); foreach (self::SIZES as $file) { $target = $public . '/' . $file; if (is_file($target)) { @@ -72,9 +72,9 @@ class TenantFaviconService } } - public static function saveUpload(string $uuid, array $file): array + public function saveUpload(string $uuid, array $file): array { - if (!self::isValidUuid($uuid)) { + if (!$this->isValidUuid($uuid)) { return ['ok' => false, 'error' => t('Tenant not found')]; } if (empty($file) || !isset($file['tmp_name'])) { @@ -88,7 +88,7 @@ class TenantFaviconService } $tmpPath = $file['tmp_name']; - $mime = self::detectMime($tmpPath); + $mime = $this->detectMime($tmpPath); if ($mime !== 'image/png') { return ['ok' => false, 'error' => t('Invalid image file')]; } @@ -96,19 +96,19 @@ class TenantFaviconService return ['ok' => false, 'error' => t('Upload failed')]; } - $dir = self::storageDir($uuid); + $dir = $this->storageDir($uuid); if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { return ['ok' => false, 'error' => t('Upload failed')]; } - self::delete($uuid); + $this->delete($uuid); $originalPath = $dir . '/original.png'; if (!move_uploaded_file($tmpPath, $originalPath)) { return ['ok' => false, 'error' => t('Upload failed')]; } @chmod($originalPath, 0644); - $publicDir = self::publicDir($uuid); + $publicDir = $this->publicDir($uuid); if (!is_dir($publicDir) && !mkdir($publicDir, 0755, true) && !is_dir($publicDir)) { return ['ok' => false, 'error' => t('Upload failed')]; } @@ -124,23 +124,23 @@ class TenantFaviconService return ['ok' => true, 'path' => $originalPath, 'mime' => $mime]; } - public static function detectMime(string $path): string + public function detectMime(string $path): string { return self::imageDetectMimeSimple($path); } - private static function storageDirs(string $uuid): array + private function storageDirs(string $uuid): array { - $dirs = [self::storageDir($uuid)]; - $legacy = self::legacyStorageDir($uuid); + $dirs = [$this->storageDir($uuid)]; + $legacy = $this->legacyStorageDir($uuid); if ($legacy !== $dirs[0]) { $dirs[] = $legacy; } return $dirs; } - private static function legacyStorageDir(string $uuid): string + private function legacyStorageDir(string $uuid): string { - return self::storageBase() . '/branding/tenants/' . $uuid . '/favicon'; + return $this->storageBase() . '/branding/tenants/' . $uuid . '/favicon'; } } diff --git a/lib/Service/Tenant/TenantScopeService.php b/lib/Service/Tenant/TenantScopeService.php index 15875a3..eb9566c 100644 --- a/lib/Service/Tenant/TenantScopeService.php +++ b/lib/Service/Tenant/TenantScopeService.php @@ -3,70 +3,92 @@ namespace MintyPHP\Service\Tenant; use MintyPHP\Repository\Org\DepartmentRepository; -use MintyPHP\Repository\Tenant\UserTenantRepository; use MintyPHP\Repository\Tenant\TenantRepository; +use MintyPHP\Repository\Tenant\UserTenantRepository; +use MintyPHP\Service\Access\PermissionGateway; use MintyPHP\Service\Access\PermissionService; class TenantScopeService { - public static function isStrict(): bool + public function __construct( + private readonly TenantRepository $tenantRepository, + private readonly DepartmentRepository $departmentRepository, + private readonly UserTenantRepository $userTenantRepository, + private readonly PermissionGateway $permissionGateway + ) { + } + + public function isStrict(): bool { return defined('TENANT_SCOPE_STRICT') ? (bool) TENANT_SCOPE_STRICT : true; } - public static function getUserTenantIds(int $userId, bool $onlyActive = true): array + public function getUserTenantIds(int $userId, bool $onlyActive = true): array { if ($userId <= 0) { return []; } - if (self::canBypass($userId)) { - $ids = TenantRepository::listIds(); + + if ($this->canBypass($userId)) { + $ids = $this->tenantRepository->listIds(); } else { - $ids = array_values(array_unique(array_map('intval', UserTenantRepository::listTenantIdsByUserId($userId)))); + $ids = array_values(array_unique(array_map( + 'intval', + $this->userTenantRepository->listTenantIdsByUserId($userId) + ))); } + if ($onlyActive) { - return self::filterActiveTenantIds($ids); + return $this->filterActiveTenantIds($ids); } + return $ids; } - public static function canAccess(string $resource, int $resourceId, int $userId): bool + public function canAccess(string $resource, int $resourceId, int $userId): bool { if ($resourceId <= 0 || $userId <= 0) { return false; } - if (self::canBypass($userId)) { + + if ($this->canBypass($userId)) { return true; } - $resourceTenantIdsRaw = self::getResourceTenantIds($resource, $resourceId); - $userTenantIdsRaw = self::getUserTenantIds($userId, false); - $resourceTenantIds = self::filterActiveTenantIds($resourceTenantIdsRaw); - $userTenantIds = self::filterActiveTenantIds($userTenantIdsRaw); + + $resourceTenantIdsRaw = $this->getResourceTenantIds($resource, $resourceId); + $userTenantIdsRaw = $this->getUserTenantIds($userId, false); + $resourceTenantIds = $this->filterActiveTenantIds($resourceTenantIdsRaw); + $userTenantIds = $this->filterActiveTenantIds($userTenantIdsRaw); if ($resourceTenantIdsRaw && !$resourceTenantIds) { return false; } if (!$resourceTenantIds) { - return self::isStrict() ? false : true; + return $this->isStrict() ? false : true; } if (!$userTenantIds) { return false; } + return (bool) array_intersect($resourceTenantIds, $userTenantIds); } - public static function filterTenantIdsForUser(array $tenantIds, int $userId): array + public function filterTenantIdsForUser(array $tenantIds, int $userId): array { $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); - $userTenantIds = self::getUserTenantIds($userId); + $userTenantIds = $this->getUserTenantIds($userId); if (!$userTenantIds) { return []; } + return array_values(array_intersect($tenantIds, $userTenantIds)); } - public static function mergeTenantIdsPreservingOutOfScope(array $requestedTenantIds, array $existingTenantIds, array $allowedTenantIds): array - { + public function mergeTenantIdsPreservingOutOfScope( + array $requestedTenantIds, + array $existingTenantIds, + array $allowedTenantIds + ): array { $requestedTenantIds = array_values(array_unique(array_map('intval', $requestedTenantIds))); $existingTenantIds = array_values(array_unique(array_map('intval', $existingTenantIds))); $allowedTenantIds = array_values(array_unique(array_map('intval', $allowedTenantIds))); @@ -74,27 +96,28 @@ class TenantScopeService if (!$allowedTenantIds) { return $existingTenantIds; } + $outOfScope = array_values(array_diff($existingTenantIds, $allowedTenantIds)); return array_values(array_unique(array_merge($requestedTenantIds, $outOfScope))); } - public static function hasGlobalAccess(int $userId): bool + public function hasGlobalAccess(int $userId): bool { - return self::canBypass($userId); + return $this->canBypass($userId); } - public static function resourceBelongsToTenant(string $resource, int $resourceId, int $tenantId): bool + public function resourceBelongsToTenant(string $resource, int $resourceId, int $tenantId): bool { if ($resourceId <= 0 || $tenantId <= 0) { return false; } - $resourceTenantIds = self::getResourceTenantIds($resource, $resourceId); + $resourceTenantIds = $this->getResourceTenantIds($resource, $resourceId); if (!$resourceTenantIds) { return false; } - $activeResourceTenantIds = self::filterActiveTenantIds($resourceTenantIds); + $activeResourceTenantIds = $this->filterActiveTenantIds($resourceTenantIds); if (!$activeResourceTenantIds) { return false; } @@ -102,38 +125,46 @@ class TenantScopeService return in_array($tenantId, $activeResourceTenantIds, true); } - private static function getResourceTenantIds(string $resource, int $resourceId): array + private function getResourceTenantIds(string $resource, int $resourceId): array { $resource = strtolower(trim($resource)); if ($resource === 'tenants') { return $resourceId > 0 ? [$resourceId] : []; } + if ($resource === 'users') { - return array_values(array_unique(array_map('intval', UserTenantRepository::listTenantIdsByUserId($resourceId)))); + return array_values(array_unique(array_map( + 'intval', + $this->userTenantRepository->listTenantIdsByUserId($resourceId) + ))); } + if ($resource === 'departments') { - $department = DepartmentRepository::find($resourceId); + $department = $this->departmentRepository->find($resourceId); $tenantId = (int) ($department['tenant_id'] ?? 0); return $tenantId > 0 ? [$tenantId] : []; } + return []; } - private static function filterActiveTenantIds(array $tenantIds): array + private function filterActiveTenantIds(array $tenantIds): array { $tenantIds = array_values(array_unique(array_map('intval', $tenantIds))); if (!$tenantIds) { return []; } - return TenantRepository::listActiveIdsByIds($tenantIds); + + return $this->tenantRepository->listActiveIdsByIds($tenantIds); } - private static function canBypass(int $userId): bool + private function canBypass(int $userId): bool { if ($userId <= 0) { return false; } - return PermissionService::userHas($userId, PermissionService::TENANTS_UPDATE) - || PermissionService::userHas($userId, PermissionService::SETTINGS_UPDATE); + + return $this->permissionGateway->userHas($userId, PermissionService::TENANTS_UPDATE) + || $this->permissionGateway->userHas($userId, PermissionService::SETTINGS_UPDATE); } } diff --git a/lib/Service/Tenant/TenantService.php b/lib/Service/Tenant/TenantService.php index a4469e6..cb29b25 100644 --- a/lib/Service/Tenant/TenantService.php +++ b/lib/Service/Tenant/TenantService.php @@ -4,42 +4,50 @@ namespace MintyPHP\Service\Tenant; use MintyPHP\Repository\Org\DepartmentRepository; use MintyPHP\Repository\Tenant\TenantRepository; -use MintyPHP\Service\Settings\SettingService; +use MintyPHP\Service\Directory\DirectorySettingsGateway; +use MintyPHP\Service\Settings\SettingServicesFactory; class TenantService { - public static function list(): array - { - return TenantRepository::list(); + public function __construct( + private readonly ?TenantRepository $tenantRepository = null, + private readonly ?DepartmentRepository $departmentRepository = null, + private readonly ?DirectorySettingsGateway $settingsGateway = null + ) { } - public static function listPaged(array $options): array + public function list(): array { - return TenantRepository::listPaged($options); + return $this->tenantRepository()->list(); } - public static function findByUuid(string $uuid): ?array + public function listPaged(array $options): array { - return TenantRepository::findByUuid($uuid); + return $this->tenantRepository()->listPaged($options); } - public static function findById(int $id): ?array + public function findByUuid(string $uuid): ?array { - return TenantRepository::find($id); + return $this->tenantRepository()->findByUuid($uuid); } - public static function createFromAdmin(array $input, int $currentUserId = 0): array + public function findById(int $id): ?array { - $form = self::sanitizeBase($input); - $form['status'] = self::normalizeStatus($form['status'] ?? ''); - $errors = self::validateBase($form); + 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]; } $statusChangedAt = gmdate('Y-m-d H:i:s'); - $createdId = TenantRepository::create([ + $createdId = $this->tenantRepository()->create([ 'description' => $form['description'], 'address' => $form['address'], 'postal_code' => $form['postal_code'], @@ -72,25 +80,25 @@ class TenantService return ['ok' => false, 'errors' => [t('Tenant can not be created')], 'form' => $form]; } - $createdTenant = TenantRepository::find((int) $createdId); + $createdTenant = $this->tenantRepository()->find((int) $createdId); $uuid = $createdTenant['uuid'] ?? null; if (!empty($input['is_default'])) { - SettingService::setDefaultTenantId((int) $createdId); + $this->settingsGateway()->setDefaultTenantId((int) $createdId); } return ['ok' => true, 'form' => $form, 'uuid' => $uuid, 'id' => (int) $createdId]; } - public static function updateFromAdmin(int $tenantId, array $input, int $currentUserId = 0): array + public function updateFromAdmin(int $tenantId, array $input, int $currentUserId = 0): array { - $form = self::sanitizeBase($input); - $form['status'] = self::normalizeStatus($form['status'] ?? ''); - $errors = self::validateBase($form); + $form = $this->sanitizeBase($input); + $form['status'] = $this->normalizeStatus($form['status'] ?? ''); + $errors = $this->validateBase($form); if ($errors) { return ['ok' => false, 'errors' => $errors, 'form' => $form]; } - $existing = TenantRepository::find($tenantId); + $existing = $this->tenantRepository()->find($tenantId); $statusChanged = false; if ($existing && isset($existing['status'])) { $statusChanged = (string) $existing['status'] !== $form['status']; @@ -127,7 +135,7 @@ class TenantService $updateData['status_changed_by'] = $currentUserId > 0 ? $currentUserId : null; } - $updated = TenantRepository::update($tenantId, $updateData); + $updated = $this->tenantRepository()->update($tenantId, $updateData); if (!$updated) { return ['ok' => false, 'errors' => [t('Tenant can not be updated')], 'form' => $form]; @@ -136,24 +144,24 @@ class TenantService return ['ok' => true, 'form' => $form]; } - public static function deleteByUuid(string $uuid): array + public function deleteByUuid(string $uuid): array { $uuid = trim($uuid); if ($uuid === '') { return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } - $tenant = TenantRepository::findByUuid($uuid); + $tenant = $this->tenantRepository()->findByUuid($uuid); if (!$tenant || !isset($tenant['id'])) { return ['ok' => false, 'status' => 404, 'error' => 'not_found']; } $tenantId = (int) $tenant['id']; - if (DepartmentRepository::countByTenantId($tenantId) > 0) { + if ($this->departmentRepository()->countByTenantId($tenantId) > 0) { return ['ok' => false, 'status' => 409, 'error' => 'tenant_has_departments']; } - $deleted = TenantRepository::delete($tenantId); + $deleted = $this->tenantRepository()->delete($tenantId); if (!$deleted) { return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; } @@ -161,7 +169,7 @@ class TenantService return ['ok' => true, 'tenant' => $tenant]; } - private static function sanitizeBase(array $input): array + private function sanitizeBase(array $input): array { return [ 'description' => trim((string) ($input['description'] ?? '')), @@ -189,7 +197,7 @@ class TenantService ]; } - private static function validateBase(array $form): array + private function validateBase(array $form): array { $errors = []; if ($form['description'] === '') { @@ -215,7 +223,7 @@ class TenantService return $errors; } - private static function normalizeStatus(string $status): string + private function normalizeStatus(string $status): string { $status = strtolower(trim($status)); if (!in_array($status, ['active', 'inactive'], true)) { @@ -223,4 +231,25 @@ class TenantService } return $status; } + + private function tenantRepository(): TenantRepository + { + return $this->tenantRepository ?? new TenantRepository(); + } + + private function departmentRepository(): DepartmentRepository + { + return $this->departmentRepository ?? new DepartmentRepository(); + } + + private function settingsGateway(): DirectorySettingsGateway + { + if ($this->settingsGateway instanceof DirectorySettingsGateway) { + return $this->settingsGateway; + } + + return new DirectorySettingsGateway( + (new SettingServicesFactory())->createSettingGateway() + ); + } } diff --git a/lib/Service/Tenant/TenantServicesFactory.php b/lib/Service/Tenant/TenantServicesFactory.php new file mode 100644 index 0000000..7675732 --- /dev/null +++ b/lib/Service/Tenant/TenantServicesFactory.php @@ -0,0 +1,66 @@ +tenantScopeService ??= new TenantScopeService( + $this->createTenantRepository(), + $this->createDepartmentRepository(), + $this->createUserTenantRepository(), + $this->createPermissionGateway() + ); + } + + public function createTenantAvatarService(): TenantAvatarService + { + return $this->tenantAvatarService ??= new TenantAvatarService(); + } + + public function createTenantFaviconService(): TenantFaviconService + { + return $this->tenantFaviconService ??= new TenantFaviconService(); + } + + public function createTenantRepository(): TenantRepository + { + return $this->tenantRepository ??= new TenantRepository(); + } + + public function createDepartmentRepository(): DepartmentRepository + { + return $this->departmentRepository ??= new DepartmentRepository(); + } + + public function createUserTenantRepository(): UserTenantRepository + { + return $this->userTenantRepository ??= new UserTenantRepository(); + } + + public function createPermissionGateway(): PermissionGateway + { + return $this->permissionGateway ??= $this->accessServicesFactory()->createPermissionGateway(); + } + + private function accessServicesFactory(): AccessServicesFactory + { + return $this->accessServicesFactory ??= new AccessServicesFactory(); + } +} diff --git a/lib/Service/User/UserAccessPdfService.php b/lib/Service/User/UserAccessPdfService.php index 66bded4..6f16ba4 100644 --- a/lib/Service/User/UserAccessPdfService.php +++ b/lib/Service/User/UserAccessPdfService.php @@ -6,7 +6,7 @@ use Dompdf\Dompdf; use Dompdf\Options; use Endroid\QrCode\QrCode; use Endroid\QrCode\Writer\PngWriter; -use MintyPHP\Service\Branding\BrandingLogoService; +use MintyPHP\Service\Branding\BrandingServicesFactory; use Throwable; use ZipArchive; @@ -168,12 +168,13 @@ class UserAccessPdfService $path = ''; $mime = ''; + $logoService = (new BrandingServicesFactory())->createBrandingLogoService(); // Prefer tenant/app branding logo first. - if (class_exists(BrandingLogoService::class) && BrandingLogoService::hasLogo()) { - $logoPath = BrandingLogoService::findLogoPath(128); + if ($logoService->hasLogo()) { + $logoPath = $logoService->findLogoPath(128); if ($logoPath && is_file($logoPath)) { $path = $logoPath; - $mime = BrandingLogoService::detectMime($logoPath); + $mime = $logoService->detectMime($logoPath); } } diff --git a/lib/Service/User/UserAccessTemplateService.php b/lib/Service/User/UserAccessTemplateService.php index 83bf09a..ab11b88 100644 --- a/lib/Service/User/UserAccessTemplateService.php +++ b/lib/Service/User/UserAccessTemplateService.php @@ -5,8 +5,7 @@ namespace MintyPHP\Service\User; use MintyPHP\Http\Request; use MintyPHP\I18n; use MintyPHP\Repository\Tenant\TenantRepository; -use MintyPHP\Repository\Tenant\UserTenantRepository; -use MintyPHP\Service\Auth\TenantSsoService; +use MintyPHP\Service\Auth\AuthServicesFactory; class UserAccessTemplateService { @@ -63,6 +62,7 @@ class UserAccessTemplateService private static function resolveTenantLoginUrl(array $user, string $locale): string { + $tenantSsoService = (new AuthServicesFactory())->createTenantSsoService(); $tenantIds = []; $primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0); $currentTenantId = (int) ($user['current_tenant_id'] ?? 0); @@ -75,18 +75,21 @@ class UserAccessTemplateService $userId = (int) ($user['id'] ?? 0); if (!$tenantIds && $userId > 0) { - $tenantIds = array_values(array_map('intval', UserTenantRepository::listTenantIdsByUserId($userId))); + $tenantIds = array_values(array_map( + 'intval', + (new UserServicesFactory())->createUserTenantRepository()->listTenantIdsByUserId($userId) + )); } foreach ($tenantIds as $tenantId) { if ($tenantId <= 0) { continue; } - $tenant = TenantRepository::find($tenantId); + $tenant = (new TenantRepository())->find($tenantId); if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') { continue; } - $tenantSlug = TenantSsoService::tenantLoginSlug($tenant); + $tenantSlug = $tenantSsoService->tenantLoginSlug($tenant); if ($tenantSlug !== '') { return appUrl(Request::withLocale('login?tenant=' . rawurlencode($tenantSlug), $locale)); } diff --git a/lib/Service/User/UserAccountService.php b/lib/Service/User/UserAccountService.php new file mode 100644 index 0000000..ba6657b --- /dev/null +++ b/lib/Service/User/UserAccountService.php @@ -0,0 +1,550 @@ + 0 && $this->scopeGateway->hasGlobalAccess($tenantUserId)) { + unset($options['tenantUserId']); + } + } + return $this->userListQueryRepository->listPaged($options); + } + + public function findByUuid(string $uuid): ?array + { + return $this->userReadRepository->findByUuid($uuid); + } + + public function findById(int $id): ?array + { + return $this->userReadRepository->find($id); + } + + public function findByEmail(string $email): ?array + { + return $this->userReadRepository->findByEmail($email); + } + + public function setLocale(int $userId, string $locale): bool + { + return $this->userWriteRepository->setLocale($userId, $locale); + } + + public function setTheme(int $userId, string $theme): bool + { + $theme = $this->normalizeTheme($theme); + return $this->userWriteRepository->setTheme($userId, $theme); + } + + public function deleteByUuid(string $uuid, int $currentUserId = 0): array + { + $uuid = trim($uuid); + if ($uuid === '') { + return ['ok' => false, 'status' => 404, 'error' => 'not_found']; + } + + $user = $this->userReadRepository->findByUuid($uuid); + if (!$user || !isset($user['id'])) { + return ['ok' => false, 'status' => 404, 'error' => 'not_found']; + } + + $userId = (int) $user['id']; + if ($currentUserId && $currentUserId === $userId) { + return [ + 'ok' => false, + 'status' => 400, + 'error' => 'self_delete', + 'message' => t('You cannot delete your own account'), + ]; + } + + $deleted = $this->userWriteRepository->delete($userId); + if (!$deleted) { + return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; + } + + return ['ok' => true, 'user' => $user]; + } + + public function deleteByUuids(array $uuids, int $currentUserId = 0): array + { + $uuids = array_values(array_filter(array_map('trim', $uuids))); + if (!$uuids) { + return ['ok' => false, 'error' => 'no_selection']; + } + + if ($currentUserId > 0) { + $uuids = $this->filterUuidsByTenantScope($uuids, $currentUserId); + if (!$uuids) { + return ['ok' => false, 'error' => 'permission_denied']; + } + } + + if ($currentUserId > 0) { + $currentUser = $this->userReadRepository->find($currentUserId); + $currentUuid = $currentUser['uuid'] ?? ''; + if ($currentUuid !== '') { + $uuids = array_values(array_filter($uuids, static fn ($uuid) => $uuid !== $currentUuid)); + } + if (!$uuids) { + return ['ok' => false, 'error' => 'self_delete']; + } + } + + $deleted = $this->userWriteRepository->deleteByUuids($uuids); + if (!$deleted) { + return ['ok' => false, 'error' => 'delete_failed']; + } + + return ['ok' => true, 'count' => count($uuids)]; + } + + public function createFromAdmin(array $input, int $currentUserId = 0): array + { + $form = $this->sanitizeBase($input); + $form['totp_secret'] = trim((string) ($input['totp_secret'] ?? '')); + $form['theme'] = $this->normalizeTheme($input['theme'] ?? null); + $form['locale'] = $this->normalizeLocale($input['locale'] ?? null); + $primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0); + if (array_key_exists('active', $input)) { + $form['active'] = isset($input['active']) ? 1 : 0; + } else { + $form['active'] = 1; + } + $password = (string) ($input['password'] ?? ''); + $password2 = (string) ($input['password2'] ?? ''); + + $errors = $this->validateBase($form); + $errors = array_merge($errors, $this->userPasswordService->validatePassword($password, $password2, true, $form['email'])); + + $tenantIds = $input['tenant_ids'] ?? []; + if (!is_array($tenantIds)) { + $tenantIds = [$tenantIds]; + } + $tenantIds = $this->normalizeTenantIds($tenantIds); + if (!$tenantIds) { + $defaultTenantId = $this->settingsGateway->getDefaultTenantId(); + if ($defaultTenantId) { + $tenantIds = [$defaultTenantId]; + } + } + [$primaryTenantId, $primaryErrors] = $this->normalizePrimaryTenant($primaryTenantId, $tenantIds); + $errors = array_merge($errors, $primaryErrors); + + if ($errors) { + $form['primary_tenant_id'] = $primaryTenantId; + return ['ok' => false, 'errors' => $errors, 'form' => $form]; + } + + $activeChangedAt = gmdate('Y-m-d H:i:s'); + $created = $this->userWriteRepository->create([ + 'first_name' => $form['first_name'], + 'last_name' => $form['last_name'], + 'email' => $form['email'], + 'profile_description' => $form['profile_description'] !== '' ? $form['profile_description'] : null, + 'job_title' => $form['job_title'] !== '' ? $form['job_title'] : null, + 'phone' => $form['phone'] !== '' ? $form['phone'] : null, + 'mobile' => $form['mobile'] !== '' ? $form['mobile'] : null, + 'short_dial' => $form['short_dial'] !== '' ? $form['short_dial'] : null, + 'address' => $form['address'] !== '' ? $form['address'] : null, + 'postal_code' => $form['postal_code'] !== '' ? $form['postal_code'] : null, + 'city' => $form['city'] !== '' ? $form['city'] : null, + 'country' => $form['country'] !== '' ? $form['country'] : null, + 'region' => $form['region'] !== '' ? $form['region'] : null, + 'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null, + 'password' => $password, + 'locale' => $form['locale'], + 'totp_secret' => $form['totp_secret'], + 'theme' => $form['theme'], + 'primary_tenant_id' => $primaryTenantId > 0 ? $primaryTenantId : null, + 'active' => $form['active'], + 'created_by' => $currentUserId > 0 ? $currentUserId : null, + 'active_changed_at' => $activeChangedAt, + 'active_changed_by' => $currentUserId > 0 ? $currentUserId : null, + ]); + + if (!$created) { + return ['ok' => false, 'errors' => [t('User can not be registered')], 'form' => $form]; + } + + $createdUser = $this->userReadRepository->findByEmail($form['email']); + $uuid = $createdUser['uuid'] ?? null; + $userId = (int) ($createdUser['id'] ?? 0); + + if ($userId > 0 && $tenantIds) { + $this->userAssignmentService->syncTenants($userId, $tenantIds); + } + + $roleIds = $this->userAssignmentService->normalizeIdInput($input['role_ids'] ?? []); + if (!$roleIds) { + $defaultRoleId = $this->settingsGateway->getDefaultRoleId(); + if ($defaultRoleId) { + $roleIds = [$defaultRoleId]; + } + } + if ($userId > 0 && $roleIds) { + $this->userAssignmentService->syncRoles($userId, $roleIds); + } + + $departmentIds = $this->userAssignmentService->normalizeIdInput($input['department_ids'] ?? []); + if (!$departmentIds) { + $defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId(); + if ($defaultDepartmentId) { + $departmentIds = [$defaultDepartmentId]; + } + } + if ($userId > 0 && $departmentIds) { + $this->userAssignmentService->syncDepartments($userId, $departmentIds); + } + + return ['ok' => true, 'form' => $form, 'uuid' => $uuid]; + } + + public function updateFromAdmin(int $userId, array $input, int $currentUserId = 0): array + { + $form = $this->sanitizeBase($input); + $form['totp_secret'] = trim((string) ($input['totp_secret'] ?? '')); + $form['locale'] = $this->normalizeLocale($input['locale'] ?? null); + $themeProvided = array_key_exists('theme', $input); + $primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0); + $tenantIdsProvided = array_key_exists('tenant_ids', $input); + $primaryProvided = array_key_exists('primary_tenant_id', $input); + $existing = $this->userReadRepository->find($userId) ?? []; + if ($themeProvided) { + $form['theme'] = $this->normalizeTheme($input['theme'] ?? null); + } else { + $form['theme'] = $this->normalizeTheme($existing['theme'] ?? null); + } + if ($tenantIdsProvided) { + $tenantIds = $input['tenant_ids'] ?? []; + if (!is_array($tenantIds)) { + $tenantIds = [$tenantIds]; + } + $tenantIds = $this->normalizeTenantIds($tenantIds); + } else { + $tenantIds = $this->userAssignmentService->buildAssignmentsForUser($userId)['tenants'] ?? []; + $tenantIds = array_values(array_map(static fn (array $tenant): int => (int) ($tenant['id'] ?? 0), $tenantIds)); + $tenantIds = array_values(array_filter($tenantIds, static fn (int $id): bool => $id > 0)); + } + if (array_key_exists('active', $input)) { + $form['active'] = isset($input['active']) ? 1 : 0; + } else { + $form['active'] = (int) ($existing['active'] ?? 1); + } + $password = (string) ($input['password'] ?? ''); + $password2 = (string) ($input['password2'] ?? ''); + + $errors = $this->validateBase($form, $userId); + if ($userId === $currentUserId && !$form['active']) { + $errors[] = t('You cannot deactivate your own account'); + } + $errors = array_merge($errors, $this->userPasswordService->validatePassword($password, $password2, false, $form['email'])); + if ($tenantIds && ($tenantIdsProvided || $primaryProvided)) { + [$primaryTenantId, $primaryErrors] = $this->normalizePrimaryTenant($primaryTenantId, $tenantIds); + $errors = array_merge($errors, $primaryErrors); + } + + if ($errors) { + if ($tenantIdsProvided || $primaryProvided) { + $form['primary_tenant_id'] = $primaryTenantId; + } + return ['ok' => false, 'errors' => $errors, 'form' => $form]; + } + + if ($tenantIdsProvided || $primaryProvided) { + $form['primary_tenant_id'] = $primaryTenantId > 0 ? $primaryTenantId : null; + } + + $updateData = [ + 'first_name' => $form['first_name'], + 'last_name' => $form['last_name'], + 'email' => $form['email'], + 'profile_description' => $form['profile_description'] !== '' ? $form['profile_description'] : null, + 'job_title' => $form['job_title'] !== '' ? $form['job_title'] : null, + 'phone' => $form['phone'] !== '' ? $form['phone'] : null, + 'mobile' => $form['mobile'] !== '' ? $form['mobile'] : null, + 'short_dial' => $form['short_dial'] !== '' ? $form['short_dial'] : null, + 'address' => $form['address'] !== '' ? $form['address'] : null, + 'postal_code' => $form['postal_code'] !== '' ? $form['postal_code'] : null, + 'city' => $form['city'] !== '' ? $form['city'] : null, + 'country' => $form['country'] !== '' ? $form['country'] : null, + 'region' => $form['region'] !== '' ? $form['region'] : null, + 'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null, + 'totp_secret' => $form['totp_secret'], + 'locale' => $form['locale'], + 'active' => $form['active'], + 'password' => $password, + 'modified_by' => $currentUserId > 0 ? $currentUserId : null, + ]; + if ($themeProvided) { + $updateData['theme'] = $form['theme']; + } + if ($tenantIdsProvided || $primaryProvided) { + $updateData['primary_tenant_id'] = $form['primary_tenant_id'] ?? null; + } + + $activeChanged = (int) ($existing['active'] ?? 1) !== (int) $form['active']; + if ($activeChanged) { + $updateData['active_changed_at'] = gmdate('Y-m-d H:i:s'); + $updateData['active_changed_by'] = $currentUserId > 0 ? $currentUserId : null; + } + + $updated = $this->userWriteRepository->update($userId, $updateData); + + if (!$updated) { + return ['ok' => false, 'errors' => [t('User can not be updated')], 'form' => $form]; + } + + if ($activeChanged) { + $this->userAssignmentService->bumpAuthzVersion($userId); + } + + return ['ok' => true, 'form' => $form]; + } + + public function setActiveByUuid(string $uuid, bool $active, int $currentUserId = 0): array + { + $uuid = trim($uuid); + if ($uuid === '') { + return ['ok' => false, 'status' => 404, 'error' => 'not_found']; + } + + $user = $this->userReadRepository->findByUuid($uuid); + if (!$user || !isset($user['id'])) { + return ['ok' => false, 'status' => 404, 'error' => 'not_found']; + } + + $userId = (int) $user['id']; + if (!$active && $currentUserId && $currentUserId === $userId) { + return [ + 'ok' => false, + 'status' => 400, + 'error' => 'self_deactivate', + 'message' => t('You cannot deactivate your own account'), + ]; + } + + $updated = $this->userWriteRepository->setActive($userId, $active, $currentUserId > 0 ? $currentUserId : null); + if (!$updated) { + return ['ok' => false, 'status' => 500, 'error' => 'update_failed']; + } + + $this->userAssignmentService->bumpAuthzVersion($userId); + + return ['ok' => true, 'user' => $user]; + } + + public function setActiveByUuids(array $uuids, bool $active, int $currentUserId = 0): array + { + $uuids = array_values(array_filter(array_map('trim', $uuids))); + if (!$uuids) { + return ['ok' => false, 'error' => 'no_selection']; + } + + if ($currentUserId > 0) { + $uuids = $this->filterUuidsByTenantScope($uuids, $currentUserId); + if (!$uuids) { + return ['ok' => false, 'error' => 'permission_denied']; + } + } + + $updated = $this->userWriteRepository->setActiveByUuids($uuids, $active, $currentUserId > 0 ? $currentUserId : null); + if (!$updated) { + return ['ok' => false, 'error' => 'update_failed']; + } + + $userIds = []; + foreach ($uuids as $uuid) { + $user = $this->userReadRepository->findByUuid((string) $uuid); + $userId = (int) ($user['id'] ?? 0); + if ($userId > 0) { + $userIds[] = $userId; + } + } + if ($userIds) { + $this->userWriteRepository->bumpAuthzVersionByUserIds($userIds); + } + + return ['ok' => true, 'count' => count($uuids)]; + } + + public function register(array $input): array + { + $form = $this->sanitizeBase($input); + $password = (string) ($input['password'] ?? ''); + $password2 = (string) ($input['password2'] ?? ''); + + $errors = $this->validateBase($form); + $errors = array_merge($errors, $this->userPasswordService->validatePassword($password, $password2, true, $form['email'])); + + if ($errors) { + return ['ok' => false, 'error' => $errors[0]]; + } + + $defaultTheme = $this->settingsGateway->getAppTheme(); + $defaultTenantId = $this->settingsGateway->getDefaultTenantId(); + $activeChangedAt = gmdate('Y-m-d H:i:s'); + $created = $this->userWriteRepository->create([ + 'first_name' => $form['first_name'], + 'last_name' => $form['last_name'], + 'email' => $form['email'], + 'password' => $password, + 'locale' => I18n::$locale, + 'totp_secret' => '', + 'theme' => $this->normalizeTheme($defaultTheme ?? 'light'), + 'primary_tenant_id' => $defaultTenantId ?: null, + 'active' => 1, + 'created_by' => null, + 'active_changed_at' => $activeChangedAt, + 'active_changed_by' => null, + ]); + + if (!$created) { + return ['ok' => false, 'error' => t('User can not be registered')]; + } + + $createdUser = $this->userReadRepository->findByEmail($form['email']); + $userId = (int) ($createdUser['id'] ?? 0); + if ($userId > 0) { + if ($defaultTenantId) { + $this->userAssignmentService->syncTenants($userId, [$defaultTenantId]); + } + $defaultRoleId = $this->settingsGateway->getDefaultRoleId(); + if ($defaultRoleId) { + $this->userAssignmentService->syncRoles($userId, [$defaultRoleId]); + } + $defaultDepartmentId = $this->settingsGateway->getDefaultDepartmentId(); + if ($defaultDepartmentId) { + $this->userAssignmentService->syncDepartments($userId, [$defaultDepartmentId]); + } + } + + return ['ok' => true]; + } + + private function filterUuidsByTenantScope(array $uuids, int $currentUserId): array + { + $allowed = []; + foreach ($uuids as $uuid) { + $user = $this->userReadRepository->findByUuid((string) $uuid); + if (!$user || !isset($user['id'])) { + continue; + } + $userId = (int) $user['id']; + if ($userId === $currentUserId) { + $allowed[] = (string) $uuid; + continue; + } + if ($this->scopeGateway->canAccess('users', $userId, $currentUserId)) { + $allowed[] = (string) $uuid; + } + } + return array_values(array_unique($allowed)); + } + + private function sanitizeBase(array $input): array + { + return [ + 'first_name' => trim((string) ($input['first_name'] ?? '')), + 'last_name' => trim((string) ($input['last_name'] ?? '')), + 'email' => trim((string) ($input['email'] ?? '')), + 'profile_description' => trim((string) ($input['profile_description'] ?? '')), + 'job_title' => trim((string) ($input['job_title'] ?? '')), + 'phone' => trim((string) ($input['phone'] ?? '')), + 'mobile' => trim((string) ($input['mobile'] ?? '')), + 'short_dial' => trim((string) ($input['short_dial'] ?? '')), + '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'] ?? '')), + 'hire_date' => trim((string) ($input['hire_date'] ?? '')), + ]; + } + + private function normalizeTheme($value): string + { + $theme = strtolower(trim((string) $value)); + $themes = function_exists('appThemes') ? appThemes() : ['light' => 'Light', 'dark' => 'Dark']; + if ($theme === '' || !isset($themes[$theme])) { + return function_exists('appDefaultTheme') ? appDefaultTheme() : 'light'; + } + return $theme; + } + + private function normalizeLocale($value): string + { + $locale = strtolower(trim((string) $value)); + $available = defined('APP_LOCALES') ? APP_LOCALES : [I18n::$defaultLocale]; + if ($locale === '' || !in_array($locale, $available, true)) { + return I18n::$locale ?? I18n::$defaultLocale; + } + return $locale; + } + + private function validateBase(array $form, ?int $excludeId = null): array + { + $errors = []; + if ($form['first_name'] === '') { + $errors[] = t('First name cannot be empty'); + } + if ($form['last_name'] === '') { + $errors[] = t('Last name cannot be empty'); + } + if ($form['email'] === '') { + $errors[] = t('Email cannot be empty'); + } elseif (!filter_var($form['email'], FILTER_VALIDATE_EMAIL)) { + $errors[] = t('Email is not valid'); + } else { + $existing = $this->userReadRepository->findByEmail($form['email']); + if ($existing && (!isset($existing['id']) || (int) $existing['id'] !== (int) $excludeId)) { + $errors[] = t('Email is already taken'); + } + } + + return $errors; + } + + private function normalizePrimaryTenant(int $primaryTenantId, array $tenantIds): array + { + if (!$tenantIds) { + return [0, []]; + } + if ($primaryTenantId > 0 && !in_array($primaryTenantId, $tenantIds, true)) { + return [$primaryTenantId, [t('Primary tenant must be one of the assigned tenants')]]; + } + if ($primaryTenantId === 0 && count($tenantIds) > 1) { + return [0, [t('Please select a primary tenant')]]; + } + if ($primaryTenantId === 0 && count($tenantIds) === 1) { + return [(int) $tenantIds[0], []]; + } + return [$primaryTenantId, []]; + } + + private function normalizeTenantIds(array $tenantIds): array + { + return $this->userAssignmentService->normalizeTenantIds($tenantIds); + } +} diff --git a/lib/Service/User/UserAssignmentService.php b/lib/Service/User/UserAssignmentService.php new file mode 100644 index 0000000..652c061 --- /dev/null +++ b/lib/Service/User/UserAssignmentService.php @@ -0,0 +1,274 @@ + [], + 'departments' => [], + 'roles' => [], + ]; + } + + $tenantIds = $this->userTenantRepository->listTenantIdsByUserId($userId); + $departmentIds = $this->userDepartmentRepository->listDepartmentIdsByUserId($userId); + $roleIds = $this->userRoleRepository->listRoleIdsByUserId($userId); + + $tenantsById = []; + foreach ($this->directoryGateway->listTenantsByIds($tenantIds) as $tenant) { + $tenantId = (int) ($tenant['id'] ?? 0); + if ($tenantId <= 0) { + continue; + } + $tenantsById[$tenantId] = [ + 'id' => $tenantId, + 'uuid' => (string) ($tenant['uuid'] ?? ''), + 'description' => (string) ($tenant['description'] ?? ''), + 'status' => (string) ($tenant['status'] ?? ''), + ]; + } + + $departmentsById = []; + foreach ($this->directoryGateway->listDepartmentsByIds($departmentIds, true) as $department) { + $departmentId = (int) ($department['id'] ?? 0); + if ($departmentId <= 0) { + continue; + } + $departmentTenantId = (int) ($department['tenant_id'] ?? 0); + $departmentsById[$departmentId] = [ + 'id' => $departmentId, + 'uuid' => (string) ($department['uuid'] ?? ''), + 'description' => (string) ($department['description'] ?? ''), + 'active' => (bool) ($department['active'] ?? 0), + 'tenant_id' => $departmentTenantId, + 'tenant_uuid' => (string) (($tenantsById[$departmentTenantId]['uuid'] ?? '')), + ]; + } + + $rolesById = []; + foreach ($this->directoryGateway->listRolesByIds($roleIds) as $role) { + $roleId = (int) ($role['id'] ?? 0); + if ($roleId <= 0) { + continue; + } + $rolesById[$roleId] = [ + 'id' => $roleId, + 'uuid' => (string) ($role['uuid'] ?? ''), + 'description' => (string) ($role['description'] ?? ''), + 'active' => (bool) ($role['active'] ?? 0), + ]; + } + + $tenants = []; + foreach ($tenantIds as $tenantId) { + if (isset($tenantsById[$tenantId])) { + $tenants[] = $tenantsById[$tenantId]; + } + } + + $departments = []; + foreach ($departmentIds as $departmentId) { + if (isset($departmentsById[$departmentId])) { + $departments[] = $departmentsById[$departmentId]; + } + } + + $roles = []; + foreach ($roleIds as $roleId) { + if (isset($rolesById[$roleId])) { + $roles[] = $rolesById[$roleId]; + } + } + + return [ + 'tenants' => $tenants, + 'departments' => $departments, + 'roles' => $roles, + ]; + } + + public function findTenantSummaryInAssignments(array $assignments, int $tenantId): ?array + { + if ($tenantId <= 0) { + return null; + } + + foreach (($assignments['tenants'] ?? []) as $tenant) { + $currentTenantId = (int) ($tenant['id'] ?? 0); + if ($currentTenantId !== $tenantId) { + continue; + } + + return [ + 'uuid' => (string) ($tenant['uuid'] ?? ''), + 'description' => (string) ($tenant['description'] ?? ''), + 'status' => (string) ($tenant['status'] ?? ''), + ]; + } + + return null; + } + + public function mapAssignmentsToPublic(array $assignments): array + { + return [ + 'tenants' => array_values(array_map( + static fn (array $tenant): array => [ + 'uuid' => (string) ($tenant['uuid'] ?? ''), + 'description' => (string) ($tenant['description'] ?? ''), + 'status' => (string) ($tenant['status'] ?? ''), + ], + $assignments['tenants'] ?? [] + )), + 'departments' => array_values(array_map( + static fn (array $department): array => [ + 'uuid' => (string) ($department['uuid'] ?? ''), + 'description' => (string) ($department['description'] ?? ''), + 'active' => (bool) ($department['active'] ?? 0), + 'tenant_uuid' => (string) ($department['tenant_uuid'] ?? ''), + ], + $assignments['departments'] ?? [] + )), + 'roles' => array_values(array_map( + static fn (array $role): array => [ + 'uuid' => (string) ($role['uuid'] ?? ''), + 'description' => (string) ($role['description'] ?? ''), + 'active' => (bool) ($role['active'] ?? 0), + ], + $assignments['roles'] ?? [] + )), + ]; + } + + public function syncTenants(int $userId, array $tenantIds, bool $bumpAuthz = true): bool + { + $ids = $this->normalizeTenantIds($tenantIds); + $result = $this->userTenantRepository->replaceForUser($userId, $ids); + if ($result && $bumpAuthz) { + $this->bumpAuthzVersion($userId); + } + return $result; + } + + public function syncRoles(int $userId, array $roleIds, bool $bumpAuthz = true): bool + { + $ids = array_values(array_unique(array_map('intval', $roleIds))); + $ids = array_values(array_filter($ids, static fn ($id) => $id > 0)); + $validIds = $this->directoryGateway->listActiveRoleIds(); + if ($validIds) { + $validMap = array_fill_keys($validIds, true); + $ids = array_values(array_filter($ids, static fn ($id) => isset($validMap[$id]))); + } + $result = $this->userRoleRepository->replaceForUser($userId, $ids); + $this->permissionGateway->clearUserCache($userId); + if ($result && $bumpAuthz) { + $this->bumpAuthzVersion($userId); + } + return $result; + } + + public function syncDepartments(int $userId, array $departmentIds, bool $bumpAuthz = true): bool + { + $ids = array_values(array_unique(array_map('intval', $departmentIds))); + $ids = array_values(array_filter($ids, static fn ($id) => $id > 0)); + // Important: use the user's assigned tenants directly (no permission bypass), + // otherwise privileged users (e.g. admins) would incorrectly allow departments + // from tenants that were just removed from their assignments. + $userTenantIds = array_values(array_unique(array_map( + 'intval', + $this->userTenantRepository->listTenantIdsByUserId($userId) + ))); + $userTenantIds = array_values(array_filter($userTenantIds, static fn ($id) => $id > 0)); + if (!$userTenantIds) { + $ids = []; + } else { + $allowedIds = $this->directoryGateway->listActiveDepartmentIdsByTenantIds($userTenantIds); + if ($allowedIds) { + $allowedMap = array_fill_keys($allowedIds, true); + $ids = array_values(array_filter($ids, static fn ($id) => isset($allowedMap[$id]))); + } else { + $ids = []; + } + } + $result = $this->userDepartmentRepository->replaceForUser($userId, $ids); + if ($result && $bumpAuthz) { + $this->bumpAuthzVersion($userId); + } + return $result; + } + + public function bumpAuthzVersion(int $userId): void + { + if ($userId <= 0) { + return; + } + $this->userWriteRepository->bumpAuthzVersion($userId); + } + + public function normalizeIdInput($value): array + { + $raw = is_array($value) ? $value : [$value]; + $flat = []; + + $collect = static function ($item) use (&$collect, &$flat): void { + if (is_array($item)) { + foreach ($item as $nested) { + $collect($nested); + } + return; + } + $text = trim((string) $item); + if ($text === '') { + return; + } + if (str_contains($text, ',')) { + foreach (explode(',', $text) as $part) { + $part = trim($part); + if ($part !== '') { + $flat[] = $part; + } + } + return; + } + $flat[] = $text; + }; + + foreach ($raw as $item) { + $collect($item); + } + + $ids = array_values(array_unique(array_map('intval', $flat))); + return array_values(array_filter($ids, static fn ($id) => $id > 0)); + } + + public function normalizeTenantIds(array $tenantIds): array + { + $ids = array_values(array_unique(array_map('intval', $tenantIds))); + $ids = array_filter($ids, static fn ($id) => $id > 0); + $validIds = $this->directoryGateway->listTenantIds(); + if ($validIds) { + $validMap = array_fill_keys($validIds, true); + $ids = array_values(array_filter($ids, static fn ($id) => isset($validMap[$id]))); + } + return $ids; + } +} diff --git a/lib/Service/User/UserAvatarService.php b/lib/Service/User/UserAvatarService.php index 06e96b5..c5f24dc 100644 --- a/lib/Service/User/UserAvatarService.php +++ b/lib/Service/User/UserAvatarService.php @@ -12,38 +12,38 @@ class UserAvatarService private const SIZES = [64, 128, 256]; private const DEFAULT_SIZE = 128; - public static function isValidUuid(string $uuid): bool + public function isValidUuid(string $uuid): bool { return self::imageIsValidUuid($uuid); } - public static function storageBase(): string + public function storageBase(): string { return self::imageStorageBase(); } - public static function userDir(string $uuid): string + public function userDir(string $uuid): string { - return self::storageBase() . '/users/' . $uuid; + return $this->storageBase() . '/users/' . $uuid; } - public static function findAvatarPath(string $uuid, ?int $size = null): ?string + public function findAvatarPath(string $uuid, ?int $size = null): ?string { - if (!self::isValidUuid($uuid)) { + if (!$this->isValidUuid($uuid)) { return null; } - $dir = self::userDir($uuid); + $dir = $this->userDir($uuid); if (!is_dir($dir)) { return null; } if ($size) { - $size = self::normalizeSize($size); - $variant = self::findVariantPath($dir, $size); + $size = $this->normalizeSize($size); + $variant = $this->findVariantPath($dir, $size); if ($variant) { return $variant; } } - $defaultVariant = self::findVariantPath($dir, self::DEFAULT_SIZE); + $defaultVariant = $this->findVariantPath($dir, self::DEFAULT_SIZE); if ($defaultVariant) { return $defaultVariant; } @@ -51,18 +51,18 @@ class UserAvatarService return $original ?: null; } - public static function hasAvatar(string $uuid): bool + public function hasAvatar(string $uuid): bool { - $path = self::findAvatarPath($uuid); + $path = $this->findAvatarPath($uuid); return $path ? is_file($path) : false; } - public static function delete(string $uuid): bool + public function delete(string $uuid): bool { - if (!self::isValidUuid($uuid)) { + if (!$this->isValidUuid($uuid)) { return false; } - $dir = self::userDir($uuid); + $dir = $this->userDir($uuid); if (!is_dir($dir)) { return true; } @@ -79,9 +79,9 @@ class UserAvatarService return true; } - public static function saveUpload(string $uuid, array $file): array + public function saveUpload(string $uuid, array $file): array { - if (!self::isValidUuid($uuid)) { + if (!$this->isValidUuid($uuid)) { return ['ok' => false, 'error' => t('User not found')]; } if (empty($file) || !isset($file['tmp_name'])) { @@ -95,7 +95,7 @@ class UserAvatarService } $tmpPath = $file['tmp_name']; - $mime = self::detectMime($tmpPath); + $mime = $this->detectMime($tmpPath); $isSvg = self::imageIsSvgUpload($mime, $tmpPath); $ext = self::imageExtensionForMime($mime, $isSvg); if (!$ext) { @@ -105,12 +105,12 @@ class UserAvatarService return ['ok' => false, 'error' => t('Invalid image file')]; } - $dir = self::userDir($uuid); + $dir = $this->userDir($uuid); if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { return ['ok' => false, 'error' => t('Upload failed')]; } - self::delete($uuid); + $this->delete($uuid); $originalPath = $dir . '/original.' . $ext; if (!move_uploaded_file($tmpPath, $originalPath)) { return ['ok' => false, 'error' => t('Upload failed')]; @@ -128,9 +128,9 @@ class UserAvatarService return ['ok' => true, 'path' => $originalPath, 'mime' => $mime]; } - public static function saveBinary(string $uuid, string $contents, string $mime = ''): array + public function saveBinary(string $uuid, string $contents, string $mime = ''): array { - if (!self::isValidUuid($uuid)) { + if (!$this->isValidUuid($uuid)) { return ['ok' => false, 'error' => t('User not found')]; } @@ -151,7 +151,7 @@ class UserAvatarService return ['ok' => false, 'error' => t('Upload failed')]; } - $detectedMime = self::detectMime($tmpPath); + $detectedMime = $this->detectMime($tmpPath); if ($detectedMime === 'application/octet-stream') { $headerMime = strtolower(trim(explode(';', $mime, 2)[0])); if (in_array($headerMime, ['image/jpeg', 'image/png', 'image/webp'], true)) { @@ -164,13 +164,13 @@ class UserAvatarService return ['ok' => false, 'error' => t('Invalid image file')]; } - $dir = self::userDir($uuid); + $dir = $this->userDir($uuid); if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { @unlink($tmpPath); return ['ok' => false, 'error' => t('Upload failed')]; } - self::delete($uuid); + $this->delete($uuid); $originalPath = $dir . '/original.' . $ext; if (!@rename($tmpPath, $originalPath)) { if (!@copy($tmpPath, $originalPath)) { @@ -192,12 +192,12 @@ class UserAvatarService return ['ok' => true, 'path' => $originalPath, 'mime' => $detectedMime]; } - public static function detectMime(string $path): string + public function detectMime(string $path): string { return self::imageDetectMime($path); } - private static function normalizeSize(int $size): int + private function normalizeSize(int $size): int { if (in_array($size, self::SIZES, true)) { return $size; @@ -205,7 +205,7 @@ class UserAvatarService return self::DEFAULT_SIZE; } - private static function findVariantPath(string $dir, int $size): ?string + private function findVariantPath(string $dir, int $size): ?string { $matches = glob($dir . '/avatar-' . $size . '.*'); if (!$matches) { diff --git a/lib/Service/User/UserDirectoryGateway.php b/lib/Service/User/UserDirectoryGateway.php new file mode 100644 index 0000000..d94a628 --- /dev/null +++ b/lib/Service/User/UserDirectoryGateway.php @@ -0,0 +1,55 @@ +listIds(); + } + + public function listTenantsByIds(array $tenantIds): array + { + return (new TenantRepository())->listByIds($tenantIds); + } + + public function findTenant(int $tenantId): ?array + { + return (new TenantRepository())->find($tenantId); + } + + public function listActiveTenantIdsByIds(array $tenantIds): array + { + return (new TenantRepository())->listActiveIdsByIds($tenantIds); + } + + public function listActiveRoleIds(): array + { + return (new RoleRepository())->listActiveIds(); + } + + public function listRolesByIds(array $roleIds): array + { + return (new RoleRepository())->listByIds($roleIds); + } + + public function listDepartmentsByIds(array $departmentIds, bool $includeInactive = true): array + { + return (new DepartmentRepository())->listByIds($departmentIds, $includeInactive); + } + + public function listActiveDepartmentIdsByTenantIds(array $tenantIds): array + { + return (new DepartmentRepository())->listActiveIdsByTenantIds($tenantIds); + } + + public function listDepartmentsByTenantIds(array $tenantIds): array + { + return (new DepartmentRepository())->listByTenantIds($tenantIds); + } +} diff --git a/lib/Service/User/UserLifecycleRestoreService.php b/lib/Service/User/UserLifecycleRestoreService.php index b59e57d..7f53413 100644 --- a/lib/Service/User/UserLifecycleRestoreService.php +++ b/lib/Service/User/UserLifecycleRestoreService.php @@ -4,12 +4,20 @@ namespace MintyPHP\Service\User; use MintyPHP\DB; use MintyPHP\Repository\Support\RepoQuery; -use MintyPHP\Repository\User\UserRepository; +use MintyPHP\Repository\User\UserReadRepository; +use MintyPHP\Repository\User\UserWriteRepository; use MintyPHP\Service\Audit\UserLifecycleAuditService; class UserLifecycleRestoreService { - public static function restoreFromLifecycleAudit(int $auditId, int $actorUserId): array + public function __construct( + private readonly UserReadRepository $userReadRepository, + private readonly UserWriteRepository $userWriteRepository, + private readonly UserLifecycleAuditService $userLifecycleAuditService + ) { + } + + public function restoreFromLifecycleAudit(int $auditId, int $actorUserId): array { if ($auditId <= 0 || $actorUserId <= 0) { return ['ok' => false, 'error' => 'invalid_request']; @@ -18,7 +26,7 @@ class UserLifecycleRestoreService $db = DB::handle(); $db->begin_transaction(); try { - $event = UserLifecycleAuditService::findDeleteEventForRestore($auditId, true); + $event = $this->userLifecycleAuditService->findDeleteEventForRestore($auditId, true); if (!$event) { $db->rollback(); return ['ok' => false, 'error' => 'audit_event_not_found']; @@ -28,7 +36,7 @@ class UserLifecycleRestoreService return ['ok' => false, 'error' => 'audit_event_already_restored']; } - $snapshot = UserLifecycleAuditService::decryptSnapshot($event); + $snapshot = $this->userLifecycleAuditService->decryptSnapshot($event); if (!$snapshot) { $db->rollback(); return ['ok' => false, 'error' => 'snapshot_unavailable']; @@ -41,35 +49,35 @@ class UserLifecycleRestoreService return ['ok' => false, 'error' => 'snapshot_invalid']; } - if (UserRepository::findByUuid($uuid)) { + if ($this->userReadRepository->findByUuid($uuid)) { $db->rollback(); return ['ok' => false, 'error' => 'restore_uuid_exists']; } - if (UserRepository::findByEmail($email)) { + if ($this->userReadRepository->findByEmail($email)) { $db->rollback(); return ['ok' => false, 'error' => 'restore_email_exists']; } - $createdId = UserRepository::create([ + $createdId = $this->userWriteRepository->create([ 'uuid' => $uuid, 'first_name' => trim((string) ($snapshot['first_name'] ?? '')), 'last_name' => trim((string) ($snapshot['last_name'] ?? '')), 'email' => $email, - 'profile_description' => self::nullableText($snapshot['profile_description'] ?? null), - 'job_title' => self::nullableText($snapshot['job_title'] ?? null), - 'phone' => self::nullableText($snapshot['phone'] ?? null), - 'mobile' => self::nullableText($snapshot['mobile'] ?? null), - 'short_dial' => self::nullableText($snapshot['short_dial'] ?? null), - 'address' => self::nullableText($snapshot['address'] ?? null), - 'postal_code' => self::nullableText($snapshot['postal_code'] ?? null), - 'city' => self::nullableText($snapshot['city'] ?? null), - 'country' => self::nullableText($snapshot['country'] ?? null), - 'region' => self::nullableText($snapshot['region'] ?? null), - 'hire_date' => self::nullableDate($snapshot['hire_date'] ?? null), - 'password' => self::randomPassword(), - 'locale' => self::nullableText($snapshot['locale'] ?? null), + 'profile_description' => $this->nullableText($snapshot['profile_description'] ?? null), + 'job_title' => $this->nullableText($snapshot['job_title'] ?? null), + 'phone' => $this->nullableText($snapshot['phone'] ?? null), + 'mobile' => $this->nullableText($snapshot['mobile'] ?? null), + 'short_dial' => $this->nullableText($snapshot['short_dial'] ?? null), + 'address' => $this->nullableText($snapshot['address'] ?? null), + 'postal_code' => $this->nullableText($snapshot['postal_code'] ?? null), + 'city' => $this->nullableText($snapshot['city'] ?? null), + 'country' => $this->nullableText($snapshot['country'] ?? null), + 'region' => $this->nullableText($snapshot['region'] ?? null), + 'hire_date' => $this->nullableDate($snapshot['hire_date'] ?? null), + 'password' => $this->randomPassword(), + 'locale' => $this->nullableText($snapshot['locale'] ?? null), 'totp_secret' => '', - 'theme' => self::normalizeTheme($snapshot['theme'] ?? null), + 'theme' => $this->normalizeTheme($snapshot['theme'] ?? null), 'primary_tenant_id' => null, 'current_tenant_id' => null, 'created_by' => $actorUserId, @@ -83,13 +91,17 @@ class UserLifecycleRestoreService } $restoredUserId = (int) $createdId; - $marked = UserLifecycleAuditService::markDeleteEventRestored($auditId, $actorUserId, $restoredUserId); + $marked = $this->userLifecycleAuditService->markDeleteEventRestored( + $auditId, + $actorUserId, + $restoredUserId + ); if (!$marked) { $db->rollback(); return ['ok' => false, 'error' => 'restore_mark_failed']; } - UserLifecycleAuditService::logRestore( + $this->userLifecycleAuditService->logRestore( RepoQuery::uuidV4(), 'manual', [ @@ -123,13 +135,13 @@ class UserLifecycleRestoreService } } - private static function nullableText(mixed $value): ?string + private function nullableText(mixed $value): ?string { $value = trim((string) $value); return $value !== '' ? $value : null; } - private static function nullableDate(mixed $value): ?string + private function nullableDate(mixed $value): ?string { $value = trim((string) $value); if ($value === '') { @@ -141,16 +153,15 @@ class UserLifecycleRestoreService return $value; } - private static function randomPassword(): string + private function randomPassword(): string { return bin2hex(random_bytes(24)); } - private static function normalizeTheme(mixed $value): string + private function normalizeTheme(mixed $value): string { $theme = strtolower(trim((string) $value)); $themes = appThemes(); return isset($themes[$theme]) ? $theme : appDefaultTheme(); } } - diff --git a/lib/Service/User/UserLifecycleService.php b/lib/Service/User/UserLifecycleService.php index 3fdc746..2a02475 100644 --- a/lib/Service/User/UserLifecycleService.php +++ b/lib/Service/User/UserLifecycleService.php @@ -3,29 +3,37 @@ namespace MintyPHP\Service\User; use MintyPHP\DB; -use MintyPHP\Repository\User\UserRepository; use MintyPHP\Repository\Support\RepoQuery; +use MintyPHP\Repository\User\UserReadRepository; +use MintyPHP\Repository\User\UserWriteRepository; use MintyPHP\Service\Access\PermissionService; use MintyPHP\Service\Audit\UserLifecycleAuditService; -use MintyPHP\Service\Settings\SettingService; class UserLifecycleService { private const LOCK_NAME = 'user_lifecycle_maintenance'; private const BATCH_SIZE = 500; - public static function run(?int $actorUserId = null): array + public function __construct( + private readonly UserReadRepository $userReadRepository, + private readonly UserWriteRepository $userWriteRepository, + private readonly UserSettingsGateway $settingsGateway, + private readonly UserLifecycleAuditService $userLifecycleAuditService + ) { + } + + public function run(?int $actorUserId = null): array { $startedAt = microtime(true); $runUuid = RepoQuery::uuidV4(); $triggerType = ($actorUserId ?? 0) > 0 ? 'manual' : 'cron'; - $deactivateDays = SettingService::getUserInactivityDeactivateDays(); - $deleteDays = SettingService::getUserInactivityDeleteDays(); + $deactivateDays = $this->settingsGateway->getUserInactivityDeactivateDays(); + $deleteDays = $this->settingsGateway->getUserInactivityDeleteDays(); if ($deactivateDays <= 0) { $deleteDays = 0; } - $privilegedUserIds = UserRepository::listPrivilegedUserIdsByPermissionKeys([ + $privilegedUserIds = $this->userReadRepository->listPrivilegedUserIdsByPermissionKeys([ PermissionService::SETTINGS_UPDATE, PermissionService::TENANTS_UPDATE, ]); @@ -46,30 +54,30 @@ class UserLifecycleService 'duration_ms' => 0, ]; - if (!self::acquireLock()) { + if (!$this->acquireLock()) { $result['ok'] = false; $result['locked'] = true; $result['error'] = 'lock_not_acquired'; - $result['duration_ms'] = self::durationMs($startedAt); + $result['duration_ms'] = $this->durationMs($startedAt); return $result; } try { if ($deactivateDays > 0) { while (true) { - $ids = UserRepository::listIdsForAutoDeactivate($deactivateDays, $privilegedUserIds, self::BATCH_SIZE); + $ids = $this->userReadRepository->listIdsForAutoDeactivate($deactivateDays, $privilegedUserIds, self::BATCH_SIZE); if (!$ids) { break; } $usersById = []; foreach ($ids as $id) { - $user = UserRepository::find((int) $id); + $user = $this->userReadRepository->find((int) $id); if ($user) { $usersById[(int) $id] = $user; } } - $updated = UserRepository::setInactiveByIds( + $updated = $this->userWriteRepository->setInactiveByIds( $ids, $actorUserId && $actorUserId > 0 ? $actorUserId : null ); @@ -79,12 +87,12 @@ class UserLifecycleService break; } $result['deactivated_count'] += $updated; - UserRepository::bumpAuthzVersionByUserIds($ids); + $this->userWriteRepository->bumpAuthzVersionByUserIds($ids); foreach ($ids as $id) { if (!isset($usersById[(int) $id])) { continue; } - UserLifecycleAuditService::logDeactivate( + $this->userLifecycleAuditService->logDeactivate( $runUuid, $triggerType, $result['policy'], @@ -102,18 +110,18 @@ class UserLifecycleService if ($deleteDays > 0) { while (true) { - $ids = UserRepository::listIdsForAutoDelete($deleteDays, $privilegedUserIds, self::BATCH_SIZE); + $ids = $this->userReadRepository->listIdsForAutoDelete($deleteDays, $privilegedUserIds, self::BATCH_SIZE); if (!$ids) { break; } foreach ($ids as $id) { - $user = UserRepository::find((int) $id); + $user = $this->userReadRepository->find((int) $id); if (!$user) { continue; } - $auditId = UserLifecycleAuditService::logDeleteWithSnapshot( + $auditId = $this->userLifecycleAuditService->logDeleteWithSnapshot( $runUuid, $triggerType, $result['policy'], @@ -122,7 +130,7 @@ class UserLifecycleService ); if (!$auditId) { $result['skipped_count']++; - UserLifecycleAuditService::logDeleteFailure( + $this->userLifecycleAuditService->logDeleteFailure( $runUuid, $triggerType, $result['policy'], @@ -133,10 +141,10 @@ class UserLifecycleService continue; } - $deleted = UserRepository::deleteByIds([(int) $id]); + $deleted = $this->userWriteRepository->deleteByIds([(int) $id]); if ($deleted <= 0) { $result['skipped_count']++; - UserLifecycleAuditService::updateStatus((int) $auditId, 'failed', 'delete_failed'); + $this->userLifecycleAuditService->updateStatus((int) $auditId, 'failed', 'delete_failed'); continue; } $result['deleted_count'] += $deleted; @@ -150,20 +158,20 @@ class UserLifecycleService $result['ok'] = false; $result['error'] = 'unexpected_error'; } finally { - self::releaseLock(); - $result['duration_ms'] = self::durationMs($startedAt); + $this->releaseLock(); + $result['duration_ms'] = $this->durationMs($startedAt); } return $result; } - private static function acquireLock(): bool + private function acquireLock(): bool { $gotLock = DB::selectValue('select GET_LOCK(?, 0) as got_lock', self::LOCK_NAME); return (int) $gotLock === 1; } - private static function releaseLock(): void + private function releaseLock(): void { try { DB::selectValue('select RELEASE_LOCK(?) as released_lock', self::LOCK_NAME); @@ -172,7 +180,7 @@ class UserLifecycleService } } - private static function durationMs(float $startedAt): int + private function durationMs(float $startedAt): int { return (int) max(0, round((microtime(true) - $startedAt) * 1000)); } diff --git a/lib/Service/User/UserPasswordPolicyService.php b/lib/Service/User/UserPasswordPolicyService.php new file mode 100644 index 0000000..1c5e5f6 --- /dev/null +++ b/lib/Service/User/UserPasswordPolicyService.php @@ -0,0 +1,74 @@ +translate('Password cannot be empty'); + return $errors; + } + if ($password !== '' && $password !== $password2) { + $errors[] = $this->translate('Passwords must match'); + } + if ($password === '') { + return $errors; + } + if (strlen($password) < self::PASSWORD_MIN_LENGTH) { + $errors[] = $this->translate('Password must be at least %d characters', self::PASSWORD_MIN_LENGTH); + } + if (!preg_match('/[A-Z]/', $password)) { + $errors[] = $this->translate('Password must include an uppercase letter'); + } + if (!preg_match('/[a-z]/', $password)) { + $errors[] = $this->translate('Password must include a lowercase letter'); + } + if (!preg_match('/\d/', $password)) { + $errors[] = $this->translate('Password must include a number'); + } + if (!preg_match('/[^a-zA-Z0-9]/', $password)) { + $errors[] = $this->translate('Password must include a symbol'); + } + if ($email !== null) { + $email = trim($email); + if ($email !== '' && stripos($password, $email) !== false) { + $errors[] = $this->translate('Password must not contain your email'); + } + } + + return $errors; + } + + public function hints(): array + { + return [ + ['rule' => 'min', 'text' => $this->translate('At least %d characters', self::PASSWORD_MIN_LENGTH)], + ['rule' => 'upper', 'text' => $this->translate('At least one uppercase letter')], + ['rule' => 'lower', 'text' => $this->translate('At least one lowercase letter')], + ['rule' => 'number', 'text' => $this->translate('At least one number')], + ['rule' => 'symbol', 'text' => $this->translate('At least one symbol')], + ['rule' => 'email', 'text' => $this->translate('Must not contain your email')], + ]; + } + + public function minLength(): int + { + return self::PASSWORD_MIN_LENGTH; + } + + private function translate(string $text, mixed ...$args): string + { + if (function_exists('t')) { + return t($text, ...$args); + } + if ($args === []) { + return $text; + } + return vsprintf($text, array_map(static fn ($arg) => (string) $arg, $args)); + } +} diff --git a/lib/Service/User/UserPasswordService.php b/lib/Service/User/UserPasswordService.php new file mode 100644 index 0000000..d1e969f --- /dev/null +++ b/lib/Service/User/UserPasswordService.php @@ -0,0 +1,48 @@ +passwordPolicyService->validate($password, $password2, $required, $email); + } + + public function passwordHints(): array + { + return $this->passwordPolicyService->hints(); + } + + public function passwordMinLength(): int + { + return $this->passwordPolicyService->minLength(); + } + + public function resetPassword(int $userId, string $password, string $password2): array + { + $errors = $this->passwordPolicyService->validate($password, $password2, true, ''); + if ($errors) { + return ['ok' => false, 'errors' => $errors]; + } + + $updated = $this->userWriteRepository->setPassword($userId, $password); + if (!$updated) { + return ['ok' => false, 'errors' => [t('Password can not be updated')]]; + } + + return ['ok' => true]; + } +} diff --git a/lib/Service/User/UserPermissionGateway.php b/lib/Service/User/UserPermissionGateway.php new file mode 100644 index 0000000..b2dc9c4 --- /dev/null +++ b/lib/Service/User/UserPermissionGateway.php @@ -0,0 +1,17 @@ +permissionGateway->clearUserCache($userId); + } +} diff --git a/lib/Service/User/UserSavedFilterService.php b/lib/Service/User/UserSavedFilterService.php index e6aad11..2c210fa 100644 --- a/lib/Service/User/UserSavedFilterService.php +++ b/lib/Service/User/UserSavedFilterService.php @@ -12,27 +12,31 @@ class UserSavedFilterService private const SEARCH_MAX_LENGTH = 200; private const MAX_PER_USER_CONTEXT = 20; - public static function listForAddressBook(int $userId): array + public function __construct(private readonly UserSavedFilterRepository $userSavedFilterRepository) { - return self::listByContext($userId, self::CONTEXT_ADDRESS_BOOK); } - public static function saveAddressBookFilter(int $userId, string $name, array $rawQuery): array + public function listForAddressBook(int $userId): array { - return self::saveByContext($userId, self::CONTEXT_ADDRESS_BOOK, $name, $rawQuery); + return $this->listByContext($userId, self::CONTEXT_ADDRESS_BOOK); } - public static function deleteAddressBookFilter(int $userId, string $uuid): bool + public function saveAddressBookFilter(int $userId, string $name, array $rawQuery): array { - return self::deleteByContext($userId, self::CONTEXT_ADDRESS_BOOK, $uuid); + return $this->saveByContext($userId, self::CONTEXT_ADDRESS_BOOK, $name, $rawQuery); } - public static function listByContext(int $userId, string $context): array + public function deleteAddressBookFilter(int $userId, string $uuid): bool + { + return $this->deleteByContext($userId, self::CONTEXT_ADDRESS_BOOK, $uuid); + } + + public function listByContext(int $userId, string $context): array { if ($userId <= 0 || $context === '') { return []; } - $rows = UserSavedFilterRepository::listByUserAndContext($userId, $context); + $rows = $this->userSavedFilterRepository->listByUserAndContext($userId, $context); $list = []; foreach ($rows as $row) { $name = trim((string) ($row['name'] ?? '')); @@ -40,7 +44,7 @@ class UserSavedFilterService if ($name === '' || $uuid === '') { continue; } - $query = self::normalizeQuery(self::decodeQuery((string) ($row['query_json'] ?? ''))); + $query = $this->normalizeQuery($this->decodeQuery((string) ($row['query_json'] ?? ''))); $list[] = [ 'id' => (int) ($row['id'] ?? 0), 'uuid' => $uuid, @@ -52,25 +56,25 @@ class UserSavedFilterService return $list; } - public static function saveByContext(int $userId, string $context, string $name, array $rawQuery): array + public function saveByContext(int $userId, string $context, string $name, array $rawQuery): array { - $name = self::truncate(trim($name), self::NAME_MAX_LENGTH); + $name = $this->truncate(trim($name), self::NAME_MAX_LENGTH); if ($userId <= 0 || $context === '') { return ['ok' => false, 'error' => 'invalid_user_or_context']; } if ($name === '') { return ['ok' => false, 'error' => 'name_required']; } - $count = UserSavedFilterRepository::countByUserAndContext($userId, $context); + $count = $this->userSavedFilterRepository->countByUserAndContext($userId, $context); if ($count >= self::MAX_PER_USER_CONTEXT) { return ['ok' => false, 'error' => 'max_reached']; } - $query = self::normalizeQuery($rawQuery); + $query = $this->normalizeQuery($rawQuery); $queryJson = json_encode($query, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if (!is_string($queryJson)) { $queryJson = '{}'; } - $id = UserSavedFilterRepository::create([ + $id = $this->userSavedFilterRepository->create([ 'user_id' => $userId, 'context' => $context, 'name' => $name, @@ -82,27 +86,27 @@ class UserSavedFilterService return ['ok' => true, 'id' => (int) $id, 'name' => $name, 'query' => $query]; } - public static function deleteByContext(int $userId, string $context, string $uuid): bool + public function deleteByContext(int $userId, string $context, string $uuid): bool { if ($userId <= 0 || $context === '' || trim($uuid) === '') { return false; } - return UserSavedFilterRepository::deleteByUuidForUserAndContext($uuid, $userId, $context); + return $this->userSavedFilterRepository->deleteByUuidForUserAndContext($uuid, $userId, $context); } - private static function decodeQuery(string $json): array + private function decodeQuery(string $json): array { $decoded = json_decode($json, true); return is_array($decoded) ? $decoded : []; } - private static function normalizeQuery(array $rawQuery): array + private function normalizeQuery(array $rawQuery): array { - $search = self::truncate(trim((string) ($rawQuery['search'] ?? '')), self::SEARCH_MAX_LENGTH); - $tenants = self::normalizeUuidList($rawQuery['tenants'] ?? []); + $search = $this->truncate(trim((string) ($rawQuery['search'] ?? '')), self::SEARCH_MAX_LENGTH); + $tenants = $this->normalizeUuidList($rawQuery['tenants'] ?? []); $departments = RepoQuery::normalizeIdList($rawQuery['departments'] ?? []); $roles = RepoQuery::normalizeIdList($rawQuery['roles'] ?? []); - $custom = self::normalizeCustomFieldQuery($rawQuery); + $custom = $this->normalizeCustomFieldQuery($rawQuery); $query = []; if ($search !== '') { @@ -124,7 +128,7 @@ class UserSavedFilterService return $query; } - private static function normalizeCustomFieldQuery(array $rawQuery): array + private function normalizeCustomFieldQuery(array $rawQuery): array { $normalized = []; foreach ($rawQuery as $rawKey => $rawValue) { @@ -158,9 +162,9 @@ class UserSavedFilterService return $normalized; } - private static function normalizeUuidList($value): array + private function normalizeUuidList($value): array { - $items = self::normalizeStringList($value); + $items = $this->normalizeStringList($value); $list = []; foreach ($items as $item) { $item = strtolower($item); @@ -173,7 +177,7 @@ class UserSavedFilterService return $list; } - private static function normalizeStringList($value): array + private function normalizeStringList($value): array { $raw = is_array($value) ? $value : [$value]; $flat = []; @@ -205,7 +209,7 @@ class UserSavedFilterService return array_values(array_unique($flat)); } - private static function truncate(string $value, int $maxLength): string + private function truncate(string $value, int $maxLength): string { if ($maxLength <= 0 || $value === '') { return $value; diff --git a/lib/Service/User/UserScopeGateway.php b/lib/Service/User/UserScopeGateway.php new file mode 100644 index 0000000..8f829c1 --- /dev/null +++ b/lib/Service/User/UserScopeGateway.php @@ -0,0 +1,37 @@ +tenantScopeService()->hasGlobalAccess($userId); + } + + public function canAccess(string $resourceType, int $resourceId, int $userId): bool + { + return $this->tenantScopeService()->canAccess($resourceType, $resourceId, $userId); + } + + public function getUserTenantIds(int $userId): array + { + return $this->tenantScopeService()->getUserTenantIds($userId); + } + + private function tenantScopeService(): TenantScopeService + { + if ($this->tenantScopeService instanceof TenantScopeService) { + return $this->tenantScopeService; + } + + return (new TenantServicesFactory())->createTenantScopeService(); + } +} diff --git a/lib/Service/User/UserService.php b/lib/Service/User/UserService.php deleted file mode 100644 index 1e51fbd..0000000 --- a/lib/Service/User/UserService.php +++ /dev/null @@ -1,1066 +0,0 @@ - 0 && TenantScopeService::hasGlobalAccess($tenantUserId)) { - unset($options['tenantUserId']); - } - } - return UserRepository::listPaged($options); - } - - public static function findByUuid(string $uuid): ?array - { - return UserRepository::findByUuid($uuid); - } - - public static function findById(int $id): ?array - { - return UserRepository::find($id); - } - - public static function findByEmail(string $email): ?array - { - return UserRepository::findByEmail($email); - } - - public static function buildAssignmentsForUser(int $userId): array - { - if ($userId <= 0) { - return [ - 'tenants' => [], - 'departments' => [], - 'roles' => [], - ]; - } - - $tenantIds = UserTenantRepository::listTenantIdsByUserId($userId); - $departmentIds = UserDepartmentRepository::listDepartmentIdsByUserId($userId); - $roleIds = UserRoleRepository::listRoleIdsByUserId($userId); - - $tenantsById = []; - foreach (TenantRepository::listByIds($tenantIds) as $tenant) { - $tenantId = (int) ($tenant['id'] ?? 0); - if ($tenantId <= 0) { - continue; - } - $tenantsById[$tenantId] = [ - 'id' => $tenantId, - 'uuid' => (string) ($tenant['uuid'] ?? ''), - 'description' => (string) ($tenant['description'] ?? ''), - 'status' => (string) ($tenant['status'] ?? ''), - ]; - } - - $departmentsById = []; - foreach (DepartmentRepository::listByIds($departmentIds, true) as $department) { - $departmentId = (int) ($department['id'] ?? 0); - if ($departmentId <= 0) { - continue; - } - $departmentTenantId = (int) ($department['tenant_id'] ?? 0); - $departmentsById[$departmentId] = [ - 'id' => $departmentId, - 'uuid' => (string) ($department['uuid'] ?? ''), - 'description' => (string) ($department['description'] ?? ''), - 'active' => (bool) ($department['active'] ?? 0), - 'tenant_id' => $departmentTenantId, - 'tenant_uuid' => (string) (($tenantsById[$departmentTenantId]['uuid'] ?? '')), - ]; - } - - $rolesById = []; - foreach (RoleRepository::listByIds($roleIds) as $role) { - $roleId = (int) ($role['id'] ?? 0); - if ($roleId <= 0) { - continue; - } - $rolesById[$roleId] = [ - 'id' => $roleId, - 'uuid' => (string) ($role['uuid'] ?? ''), - 'description' => (string) ($role['description'] ?? ''), - 'active' => (bool) ($role['active'] ?? 0), - ]; - } - - $tenants = []; - foreach ($tenantIds as $tenantId) { - if (isset($tenantsById[$tenantId])) { - $tenants[] = $tenantsById[$tenantId]; - } - } - - $departments = []; - foreach ($departmentIds as $departmentId) { - if (isset($departmentsById[$departmentId])) { - $departments[] = $departmentsById[$departmentId]; - } - } - - $roles = []; - foreach ($roleIds as $roleId) { - if (isset($rolesById[$roleId])) { - $roles[] = $rolesById[$roleId]; - } - } - - return [ - 'tenants' => $tenants, - 'departments' => $departments, - 'roles' => $roles, - ]; - } - - public static function findTenantSummaryInAssignments(array $assignments, int $tenantId): ?array - { - if ($tenantId <= 0) { - return null; - } - - foreach (($assignments['tenants'] ?? []) as $tenant) { - $currentTenantId = (int) ($tenant['id'] ?? 0); - if ($currentTenantId !== $tenantId) { - continue; - } - - return [ - 'uuid' => (string) ($tenant['uuid'] ?? ''), - 'description' => (string) ($tenant['description'] ?? ''), - 'status' => (string) ($tenant['status'] ?? ''), - ]; - } - - return null; - } - - public static function mapAssignmentsToPublic(array $assignments): array - { - return [ - 'tenants' => array_values(array_map( - static fn (array $tenant): array => [ - 'uuid' => (string) ($tenant['uuid'] ?? ''), - 'description' => (string) ($tenant['description'] ?? ''), - 'status' => (string) ($tenant['status'] ?? ''), - ], - $assignments['tenants'] ?? [] - )), - 'departments' => array_values(array_map( - static fn (array $department): array => [ - 'uuid' => (string) ($department['uuid'] ?? ''), - 'description' => (string) ($department['description'] ?? ''), - 'active' => (bool) ($department['active'] ?? 0), - 'tenant_uuid' => (string) ($department['tenant_uuid'] ?? ''), - ], - $assignments['departments'] ?? [] - )), - 'roles' => array_values(array_map( - static fn (array $role): array => [ - 'uuid' => (string) ($role['uuid'] ?? ''), - 'description' => (string) ($role['description'] ?? ''), - 'active' => (bool) ($role['active'] ?? 0), - ], - $assignments['roles'] ?? [] - )), - ]; - } - - public static function setLocale(int $userId, string $locale): bool - { - return UserRepository::setLocale($userId, $locale); - } - - public static function setTheme(int $userId, string $theme): bool - { - $theme = self::normalizeTheme($theme); - return UserRepository::setTheme($userId, $theme); - } - - public static function deleteByUuid(string $uuid, int $currentUserId = 0): array - { - $uuid = trim($uuid); - if ($uuid === '') { - return ['ok' => false, 'status' => 404, 'error' => 'not_found']; - } - - $user = UserRepository::findByUuid($uuid); - if (!$user || !isset($user['id'])) { - return ['ok' => false, 'status' => 404, 'error' => 'not_found']; - } - - $userId = (int) $user['id']; - if ($currentUserId && $currentUserId === $userId) { - return [ - 'ok' => false, - 'status' => 400, - 'error' => 'self_delete', - 'message' => t('You cannot delete your own account'), - ]; - } - - $deleted = UserRepository::delete($userId); - if (!$deleted) { - return ['ok' => false, 'status' => 500, 'error' => 'delete_failed']; - } - - return ['ok' => true, 'user' => $user]; - } - - public static function deleteByUuids(array $uuids, int $currentUserId = 0): array - { - $uuids = array_values(array_filter(array_map('trim', $uuids))); - if (!$uuids) { - return ['ok' => false, 'error' => 'no_selection']; - } - - if ($currentUserId > 0) { - $uuids = self::filterUuidsByTenantScope($uuids, $currentUserId); - if (!$uuids) { - return ['ok' => false, 'error' => 'permission_denied']; - } - } - - if ($currentUserId > 0) { - $currentUser = UserRepository::find($currentUserId); - $currentUuid = $currentUser['uuid'] ?? ''; - if ($currentUuid !== '') { - $uuids = array_values(array_filter($uuids, static fn ($uuid) => $uuid !== $currentUuid)); - } - if (!$uuids) { - return ['ok' => false, 'error' => 'self_delete']; - } - } - - $deleted = UserRepository::deleteByUuids($uuids); - if (!$deleted) { - return ['ok' => false, 'error' => 'delete_failed']; - } - - return ['ok' => true, 'count' => count($uuids)]; - } - - public static function createFromAdmin(array $input, int $currentUserId = 0): array - { - $form = self::sanitizeBase($input); - $form['totp_secret'] = trim((string) ($input['totp_secret'] ?? '')); - $form['theme'] = self::normalizeTheme($input['theme'] ?? null); - $form['locale'] = self::normalizeLocale($input['locale'] ?? null); - $primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0); - if (array_key_exists('active', $input)) { - $form['active'] = isset($input['active']) ? 1 : 0; - } else { - $form['active'] = 1; - } - $password = (string) ($input['password'] ?? ''); - $password2 = (string) ($input['password2'] ?? ''); - - $errors = self::validateBase($form); - $errors = array_merge($errors, self::validatePassword($password, $password2, true, $form['email'])); - - $tenantIds = $input['tenant_ids'] ?? []; - if (!is_array($tenantIds)) { - $tenantIds = [$tenantIds]; - } - $tenantIds = self::normalizeTenantIds($tenantIds); - if (!$tenantIds) { - $defaultTenantId = SettingService::getDefaultTenantId(); - if ($defaultTenantId) { - $tenantIds = [$defaultTenantId]; - } - } - [$primaryTenantId, $primaryErrors] = self::normalizePrimaryTenant($primaryTenantId, $tenantIds); - $errors = array_merge($errors, $primaryErrors); - - if ($errors) { - $form['primary_tenant_id'] = $primaryTenantId; - return ['ok' => false, 'errors' => $errors, 'form' => $form]; - } - - $activeChangedAt = gmdate('Y-m-d H:i:s'); - $created = UserRepository::create([ - 'first_name' => $form['first_name'], - 'last_name' => $form['last_name'], - 'email' => $form['email'], - 'profile_description' => $form['profile_description'] !== '' ? $form['profile_description'] : null, - 'job_title' => $form['job_title'] !== '' ? $form['job_title'] : null, - 'phone' => $form['phone'] !== '' ? $form['phone'] : null, - 'mobile' => $form['mobile'] !== '' ? $form['mobile'] : null, - 'short_dial' => $form['short_dial'] !== '' ? $form['short_dial'] : null, - 'address' => $form['address'] !== '' ? $form['address'] : null, - 'postal_code' => $form['postal_code'] !== '' ? $form['postal_code'] : null, - 'city' => $form['city'] !== '' ? $form['city'] : null, - 'country' => $form['country'] !== '' ? $form['country'] : null, - 'region' => $form['region'] !== '' ? $form['region'] : null, - 'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null, - 'password' => $password, - 'locale' => $form['locale'], - 'totp_secret' => $form['totp_secret'], - 'theme' => $form['theme'], - 'primary_tenant_id' => $primaryTenantId > 0 ? $primaryTenantId : null, - 'active' => $form['active'], - 'created_by' => $currentUserId > 0 ? $currentUserId : null, - 'active_changed_at' => $activeChangedAt, - 'active_changed_by' => $currentUserId > 0 ? $currentUserId : null, - ]); - - if (!$created) { - return ['ok' => false, 'errors' => [t('User can not be registered')], 'form' => $form]; - } - - $createdUser = UserRepository::findByEmail($form['email']); - $uuid = $createdUser['uuid'] ?? null; - $userId = (int) ($createdUser['id'] ?? 0); - - if ($userId > 0 && $tenantIds) { - self::syncTenants($userId, $tenantIds); - } - - $roleIds = self::normalizeIdInput($input['role_ids'] ?? []); - if (!$roleIds) { - $defaultRoleId = SettingService::getDefaultRoleId(); - if ($defaultRoleId) { - $roleIds = [$defaultRoleId]; - } - } - if ($userId > 0 && $roleIds) { - self::syncRoles($userId, $roleIds); - } - - $departmentIds = self::normalizeIdInput($input['department_ids'] ?? []); - if (!$departmentIds) { - $defaultDepartmentId = SettingService::getDefaultDepartmentId(); - if ($defaultDepartmentId) { - $departmentIds = [$defaultDepartmentId]; - } - } - if ($userId > 0 && $departmentIds) { - self::syncDepartments($userId, $departmentIds); - } - - return ['ok' => true, 'form' => $form, 'uuid' => $uuid]; - } - - public static function updateFromAdmin(int $userId, array $input, int $currentUserId = 0): array - { - $form = self::sanitizeBase($input); - $form['totp_secret'] = trim((string) ($input['totp_secret'] ?? '')); - $form['locale'] = self::normalizeLocale($input['locale'] ?? null); - $themeProvided = array_key_exists('theme', $input); - $primaryTenantId = (int) ($input['primary_tenant_id'] ?? 0); - $tenantIdsProvided = array_key_exists('tenant_ids', $input); - $primaryProvided = array_key_exists('primary_tenant_id', $input); - $existing = UserRepository::find($userId) ?? []; - if ($themeProvided) { - $form['theme'] = self::normalizeTheme($input['theme'] ?? null); - } else { - $form['theme'] = self::normalizeTheme($existing['theme'] ?? null); - } - if ($tenantIdsProvided) { - $tenantIds = $input['tenant_ids'] ?? []; - if (!is_array($tenantIds)) { - $tenantIds = [$tenantIds]; - } - $tenantIds = self::normalizeTenantIds($tenantIds); - } else { - $tenantIds = UserTenantRepository::listTenantIdsByUserId($userId); - } - if (array_key_exists('active', $input)) { - $form['active'] = isset($input['active']) ? 1 : 0; - } else { - $form['active'] = (int) ($existing['active'] ?? 1); - } - $password = (string) ($input['password'] ?? ''); - $password2 = (string) ($input['password2'] ?? ''); - - $errors = self::validateBase($form, $userId); - if ($userId === $currentUserId && !$form['active']) { - $errors[] = t('You cannot deactivate your own account'); - } - $errors = array_merge($errors, self::validatePassword($password, $password2, false, $form['email'])); - if ($tenantIds && ($tenantIdsProvided || $primaryProvided)) { - [$primaryTenantId, $primaryErrors] = self::normalizePrimaryTenant($primaryTenantId, $tenantIds); - $errors = array_merge($errors, $primaryErrors); - } - - if ($errors) { - if ($tenantIdsProvided || $primaryProvided) { - $form['primary_tenant_id'] = $primaryTenantId; - } - return ['ok' => false, 'errors' => $errors, 'form' => $form]; - } - - if ($tenantIdsProvided || $primaryProvided) { - $form['primary_tenant_id'] = $primaryTenantId > 0 ? $primaryTenantId : null; - } - - $updateData = [ - 'first_name' => $form['first_name'], - 'last_name' => $form['last_name'], - 'email' => $form['email'], - 'profile_description' => $form['profile_description'] !== '' ? $form['profile_description'] : null, - 'job_title' => $form['job_title'] !== '' ? $form['job_title'] : null, - 'phone' => $form['phone'] !== '' ? $form['phone'] : null, - 'mobile' => $form['mobile'] !== '' ? $form['mobile'] : null, - 'short_dial' => $form['short_dial'] !== '' ? $form['short_dial'] : null, - 'address' => $form['address'] !== '' ? $form['address'] : null, - 'postal_code' => $form['postal_code'] !== '' ? $form['postal_code'] : null, - 'city' => $form['city'] !== '' ? $form['city'] : null, - 'country' => $form['country'] !== '' ? $form['country'] : null, - 'region' => $form['region'] !== '' ? $form['region'] : null, - 'hire_date' => $form['hire_date'] !== '' ? $form['hire_date'] : null, - 'totp_secret' => $form['totp_secret'], - 'locale' => $form['locale'], - 'active' => $form['active'], - 'password' => $password, - 'modified_by' => $currentUserId > 0 ? $currentUserId : null, - ]; - if ($themeProvided) { - $updateData['theme'] = $form['theme']; - } - if ($tenantIdsProvided || $primaryProvided) { - $updateData['primary_tenant_id'] = $form['primary_tenant_id'] ?? null; - } - - $activeChanged = (int) ($existing['active'] ?? 1) !== (int) $form['active']; - if ($activeChanged) { - $updateData['active_changed_at'] = gmdate('Y-m-d H:i:s'); - $updateData['active_changed_by'] = $currentUserId > 0 ? $currentUserId : null; - } - - $updated = UserRepository::update($userId, $updateData); - - if (!$updated) { - return ['ok' => false, 'errors' => [t('User can not be updated')], 'form' => $form]; - } - - if ($activeChanged) { - self::bumpAuthzVersion($userId); - } - - return ['ok' => true, 'form' => $form]; - } - - public static function setActiveByUuid(string $uuid, bool $active, int $currentUserId = 0): array - { - $uuid = trim($uuid); - if ($uuid === '') { - return ['ok' => false, 'status' => 404, 'error' => 'not_found']; - } - - $user = UserRepository::findByUuid($uuid); - if (!$user || !isset($user['id'])) { - return ['ok' => false, 'status' => 404, 'error' => 'not_found']; - } - - $userId = (int) $user['id']; - if (!$active && $currentUserId && $currentUserId === $userId) { - return [ - 'ok' => false, - 'status' => 400, - 'error' => 'self_deactivate', - 'message' => t('You cannot deactivate your own account'), - ]; - } - - $updated = UserRepository::setActive($userId, $active, $currentUserId > 0 ? $currentUserId : null); - if (!$updated) { - return ['ok' => false, 'status' => 500, 'error' => 'update_failed']; - } - - self::bumpAuthzVersion($userId); - - return ['ok' => true, 'user' => $user]; - } - - public static function setActiveByUuids(array $uuids, bool $active, int $currentUserId = 0): array - { - $uuids = array_values(array_filter(array_map('trim', $uuids))); - if (!$uuids) { - return ['ok' => false, 'error' => 'no_selection']; - } - - if ($currentUserId > 0) { - $uuids = self::filterUuidsByTenantScope($uuids, $currentUserId); - if (!$uuids) { - return ['ok' => false, 'error' => 'permission_denied']; - } - } - - $updated = UserRepository::setActiveByUuids($uuids, $active, $currentUserId > 0 ? $currentUserId : null); - if (!$updated) { - return ['ok' => false, 'error' => 'update_failed']; - } - - $userIds = []; - foreach ($uuids as $uuid) { - $user = UserRepository::findByUuid((string) $uuid); - $userId = (int) ($user['id'] ?? 0); - if ($userId > 0) { - $userIds[] = $userId; - } - } - if ($userIds) { - UserRepository::bumpAuthzVersionByUserIds($userIds); - } - - return ['ok' => true, 'count' => count($uuids)]; - } - - private static function filterUuidsByTenantScope(array $uuids, int $currentUserId): array - { - $allowed = []; - foreach ($uuids as $uuid) { - $user = UserRepository::findByUuid((string) $uuid); - if (!$user || !isset($user['id'])) { - continue; - } - $userId = (int) $user['id']; - if ($userId === $currentUserId) { - $allowed[] = (string) $uuid; - continue; - } - if (TenantScopeService::canAccess('users', $userId, $currentUserId)) { - $allowed[] = (string) $uuid; - } - } - return array_values(array_unique($allowed)); - } - - public static function register(array $input): array - { - $form = self::sanitizeBase($input); - $password = (string) ($input['password'] ?? ''); - $password2 = (string) ($input['password2'] ?? ''); - - $errors = self::validateBase($form); - $errors = array_merge($errors, self::validatePassword($password, $password2, true, $form['email'])); - - if ($errors) { - return ['ok' => false, 'error' => $errors[0]]; - } - - $defaultTheme = SettingService::getAppTheme(); - $defaultTenantId = SettingService::getDefaultTenantId(); - $activeChangedAt = gmdate('Y-m-d H:i:s'); - $created = UserRepository::create([ - 'first_name' => $form['first_name'], - 'last_name' => $form['last_name'], - 'email' => $form['email'], - 'password' => $password, - 'locale' => I18n::$locale, - 'totp_secret' => '', - 'theme' => self::normalizeTheme($defaultTheme ?? 'light'), - 'primary_tenant_id' => $defaultTenantId ?: null, - 'active' => 1, - 'created_by' => null, - 'active_changed_at' => $activeChangedAt, - 'active_changed_by' => null, - ]); - - if (!$created) { - return ['ok' => false, 'error' => t('User can not be registered')]; - } - - $createdUser = UserRepository::findByEmail($form['email']); - $userId = (int) ($createdUser['id'] ?? 0); - if ($userId > 0) { - if ($defaultTenantId) { - self::syncTenants($userId, [$defaultTenantId]); - } - $defaultRoleId = SettingService::getDefaultRoleId(); - if ($defaultRoleId) { - self::syncRoles($userId, [$defaultRoleId]); - } - $defaultDepartmentId = SettingService::getDefaultDepartmentId(); - if ($defaultDepartmentId) { - self::syncDepartments($userId, [$defaultDepartmentId]); - } - } - - return ['ok' => true]; - } - - public static function syncTenants(int $userId, array $tenantIds, bool $bumpAuthz = true): bool - { - $ids = self::normalizeTenantIds($tenantIds); - $result = UserTenantRepository::replaceForUser($userId, $ids); - if ($result && $bumpAuthz) { - self::bumpAuthzVersion($userId); - } - return $result; - } - - public static function syncRoles(int $userId, array $roleIds, bool $bumpAuthz = true): bool - { - $ids = array_values(array_unique(array_map('intval', $roleIds))); - $ids = array_filter($ids, static fn ($id) => $id > 0); - $validIds = RoleRepository::listActiveIds(); - if ($validIds) { - $validMap = array_fill_keys($validIds, true); - $ids = array_values(array_filter($ids, static fn ($id) => isset($validMap[$id]))); - } - $result = UserRoleRepository::replaceForUser($userId, $ids); - PermissionService::clearUserCache($userId); - if ($result && $bumpAuthz) { - self::bumpAuthzVersion($userId); - } - return $result; - } - - public static function syncDepartments(int $userId, array $departmentIds, bool $bumpAuthz = true): bool - { - $ids = array_values(array_unique(array_map('intval', $departmentIds))); - $ids = array_filter($ids, static fn ($id) => $id > 0); - // Important: use the user's assigned tenants directly (no permission bypass), - // otherwise privileged users (e.g. admins) would incorrectly allow departments - // from tenants that were just removed from their assignments. - $userTenantIds = array_values(array_unique(array_map( - 'intval', - UserTenantRepository::listTenantIdsByUserId($userId) - ))); - $userTenantIds = array_values(array_filter($userTenantIds, static fn ($id) => $id > 0)); - if (!$userTenantIds) { - $ids = []; - } else { - $allowedIds = DepartmentRepository::listActiveIdsByTenantIds($userTenantIds); - if ($allowedIds) { - $allowedMap = array_fill_keys($allowedIds, true); - $ids = array_values(array_filter($ids, static fn ($id) => isset($allowedMap[$id]))); - } else { - $ids = []; - } - } - $result = UserDepartmentRepository::replaceForUser($userId, $ids); - if ($result && $bumpAuthz) { - self::bumpAuthzVersion($userId); - } - return $result; - } - - public static function bumpAuthzVersion(int $userId): void - { - if ($userId <= 0) { - return; - } - UserRepository::bumpAuthzVersion($userId); - } - - private static function sanitizeBase(array $input): array - { - return [ - 'first_name' => trim((string) ($input['first_name'] ?? '')), - 'last_name' => trim((string) ($input['last_name'] ?? '')), - 'email' => trim((string) ($input['email'] ?? '')), - 'profile_description' => trim((string) ($input['profile_description'] ?? '')), - 'job_title' => trim((string) ($input['job_title'] ?? '')), - 'phone' => trim((string) ($input['phone'] ?? '')), - 'mobile' => trim((string) ($input['mobile'] ?? '')), - 'short_dial' => trim((string) ($input['short_dial'] ?? '')), - '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'] ?? '')), - 'hire_date' => trim((string) ($input['hire_date'] ?? '')), - ]; - } - - public static function normalizeIdInput($value): array - { - $raw = is_array($value) ? $value : [$value]; - $flat = []; - - $collect = static function ($item) use (&$collect, &$flat): void { - if (is_array($item)) { - foreach ($item as $nested) { - $collect($nested); - } - return; - } - $text = trim((string) $item); - if ($text === '') { - return; - } - if (str_contains($text, ',')) { - foreach (explode(',', $text) as $part) { - $part = trim($part); - if ($part !== '') { - $flat[] = $part; - } - } - return; - } - $flat[] = $text; - }; - - foreach ($raw as $item) { - $collect($item); - } - - $ids = array_values(array_unique(array_map('intval', $flat))); - return array_values(array_filter($ids, static fn ($id) => $id > 0)); - } - - private static function normalizeTenantIds(array $tenantIds): array - { - $ids = array_values(array_unique(array_map('intval', $tenantIds))); - $ids = array_filter($ids, static fn ($id) => $id > 0); - $validIds = TenantRepository::listIds(); - if ($validIds) { - $validMap = array_fill_keys($validIds, true); - $ids = array_values(array_filter($ids, static fn ($id) => isset($validMap[$id]))); - } - return $ids; - } - - private static function normalizeTheme($value): string - { - $theme = strtolower(trim((string) $value)); - $themes = function_exists('appThemes') ? appThemes() : ['light' => 'Light', 'dark' => 'Dark']; - if ($theme === '' || !isset($themes[$theme])) { - return function_exists('appDefaultTheme') ? appDefaultTheme() : 'light'; - } - return $theme; - } - - private static function normalizeLocale($value): string - { - $locale = strtolower(trim((string) $value)); - $available = defined('APP_LOCALES') ? APP_LOCALES : [I18n::$defaultLocale]; - if ($locale === '' || !in_array($locale, $available, true)) { - return I18n::$locale ?? I18n::$defaultLocale; - } - return $locale; - } - - private static function validateBase(array $form, ?int $excludeId = null): array - { - $errors = []; - if ($form['first_name'] === '') { - $errors[] = t('First name cannot be empty'); - } - if ($form['last_name'] === '') { - $errors[] = t('Last name cannot be empty'); - } - if ($form['email'] === '') { - $errors[] = t('Email cannot be empty'); - } elseif (!filter_var($form['email'], FILTER_VALIDATE_EMAIL)) { - $errors[] = t('Email is not valid'); - } else { - $existing = UserRepository::findByEmail($form['email']); - if ($existing && (!isset($existing['id']) || (int) $existing['id'] !== (int) $excludeId)) { - $errors[] = t('Email is already taken'); - } - } - - return $errors; - } - - private static function normalizePrimaryTenant(int $primaryTenantId, array $tenantIds): array - { - if (!$tenantIds) { - return [0, []]; - } - if ($primaryTenantId > 0 && !in_array($primaryTenantId, $tenantIds, true)) { - return [$primaryTenantId, [t('Primary tenant must be one of the assigned tenants')]]; - } - if ($primaryTenantId === 0 && count($tenantIds) > 1) { - return [0, [t('Please select a primary tenant')]]; - } - if ($primaryTenantId === 0 && count($tenantIds) === 1) { - return [(int) $tenantIds[0], []]; - } - return [$primaryTenantId, []]; - } - - public static function validatePassword( - string $password, - string $password2, - bool $required, - ?string $email - ): array { - return self::validatePasswordStrength($password, $password2, $required, $email); - } - - public static function passwordHints(): array - { - return [ - ['rule' => 'min', 'text' => t('At least %d characters', self::PASSWORD_MIN_LENGTH)], - ['rule' => 'upper', 'text' => t('At least one uppercase letter')], - ['rule' => 'lower', 'text' => t('At least one lowercase letter')], - ['rule' => 'number', 'text' => t('At least one number')], - ['rule' => 'symbol', 'text' => t('At least one symbol')], - ['rule' => 'email', 'text' => t('Must not contain your email')], - ]; - } - - public static function passwordMinLength(): int - { - return self::PASSWORD_MIN_LENGTH; - } - - public static function resetPassword(int $userId, string $password, string $password2): array - { - $errors = self::validatePasswordStrength($password, $password2, true, ''); - if ($errors) { - return ['ok' => false, 'errors' => $errors]; - } - - $updated = UserRepository::setPassword($userId, $password); - if (!$updated) { - return ['ok' => false, 'errors' => [t('Password can not be updated')]]; - } - - return ['ok' => true]; - } - - private static function validatePasswordStrength( - string $password, - string $password2, - bool $required, - ?string $email - ): array { - $errors = []; - if ($required && $password === '') { - $errors[] = t('Password cannot be empty'); - return $errors; - } - if ($password !== '' && $password !== $password2) { - $errors[] = t('Passwords must match'); - } - if ($password === '') { - return $errors; - } - if (strlen($password) < self::PASSWORD_MIN_LENGTH) { - $errors[] = t('Password must be at least %d characters', self::PASSWORD_MIN_LENGTH); - } - if (!preg_match('/[A-Z]/', $password)) { - $errors[] = t('Password must include an uppercase letter'); - } - if (!preg_match('/[a-z]/', $password)) { - $errors[] = t('Password must include a lowercase letter'); - } - if (!preg_match('/\\d/', $password)) { - $errors[] = t('Password must include a number'); - } - if (!preg_match('/[^a-zA-Z0-9]/', $password)) { - $errors[] = t('Password must include a symbol'); - } - if ($email) { - $email = trim((string) $email); - if ($email !== '' && stripos($password, $email) !== false) { - $errors[] = t('Password must not contain your email'); - } - } - return $errors; - } - - /** - * Get the current tenant ID for a user (with fallback to primary tenant) - */ - public static function getCurrentTenantId(int $userId): ?int - { - $user = UserRepository::find($userId); - if (!$user) { - return null; - } - - $activeTenantIds = TenantScopeService::getUserTenantIds($userId); - if (!$activeTenantIds) { - return null; - } - - $currentTenantId = (int) ($user['current_tenant_id'] ?? 0); - if ($currentTenantId > 0 && in_array($currentTenantId, $activeTenantIds, true)) { - return $currentTenantId; - } - - $primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0); - if ($primaryTenantId > 0 && in_array($primaryTenantId, $activeTenantIds, true)) { - return $primaryTenantId; - } - - return $activeTenantIds[0] ?? null; - } - - /** - * Get the current tenant data for a user - */ - public static function getCurrentTenant(int $userId): ?array - { - $currentTenantId = self::getCurrentTenantId($userId); - if (!$currentTenantId) { - return null; - } - - return TenantRepository::find($currentTenantId); - } - - /** - * Get all tenants the user has access to - */ - public static function getAvailableTenants(int $userId): array - { - $tenantIds = TenantScopeService::getUserTenantIds($userId); - if (!$tenantIds) { - return []; - } - - $tenants = []; - foreach ($tenantIds as $tenantId) { - $tenant = TenantRepository::find($tenantId); - if ($tenant && (($tenant['status'] ?? 'active') === 'active')) { - $tenants[] = $tenant; - } - } - - // Sort by description - usort($tenants, static function ($a, $b) { - return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? '')); - }); - - return $tenants; - } - - /** - * Get only explicitly assigned active tenants for a user. - * This intentionally ignores global tenant-scope bypass permissions. - */ - public static function getAssignedActiveTenants(int $userId): array - { - if ($userId <= 0) { - return []; - } - - $tenantIds = array_values(array_unique(array_map( - 'intval', - UserTenantRepository::listTenantIdsByUserId($userId) - ))); - $tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0)); - if (!$tenantIds) { - return []; - } - - $activeTenantIds = TenantRepository::listActiveIdsByIds($tenantIds); - if (!$activeTenantIds) { - return []; - } - - $tenantsById = []; - foreach (TenantRepository::listByIds($activeTenantIds) as $tenant) { - $tenantId = (int) ($tenant['id'] ?? 0); - if ($tenantId <= 0) { - continue; - } - $tenantsById[$tenantId] = $tenant; - } - - $tenants = []; - foreach ($tenantIds as $tenantId) { - if (isset($tenantsById[$tenantId])) { - $tenants[] = $tenantsById[$tenantId]; - } - } - - usort($tenants, static function ($a, $b) { - return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? '')); - }); - - return $tenants; - } - - /** - * Get available departments grouped by tenant for a user. - * Returns an array of groups: ['tenant' => [...], 'departments' => [...]] - */ - public static function getAvailableDepartmentsByTenant(int $userId): array - { - $tenants = self::getAvailableTenants($userId); - if (!$tenants) { - return []; - } - - $groups = []; - foreach ($tenants as $tenant) { - $tenantId = (int) ($tenant['id'] ?? 0); - if ($tenantId <= 0) { - continue; - } - $departments = DepartmentRepository::listByTenantIds([$tenantId]); - usort($departments, static function ($a, $b) { - return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? '')); - }); - $departments = array_map(static function (array $department): array { - return [ - 'id' => (int) ($department['id'] ?? 0), - 'uuid' => $department['uuid'] ?? '', - 'description' => $department['description'] ?? '', - ]; - }, $departments); - - $groups[] = [ - 'tenant' => [ - 'id' => (int) ($tenant['id'] ?? 0), - 'uuid' => $tenant['uuid'] ?? '', - 'description' => $tenant['description'] ?? '', - ], - 'departments' => $departments, - ]; - } - - return $groups; - } - - /** - * Set the current tenant for a user (with validation) - */ - public static function setCurrentTenant(int $userId, int $tenantId): array - { - if ($tenantId <= 0) { - return ['ok' => false, 'error' => 'invalid_tenant']; - } - - // Verify user has access to this tenant - $userTenantIds = UserTenantRepository::listTenantIdsByUserId($userId); - if (!in_array($tenantId, $userTenantIds, true)) { - return ['ok' => false, 'error' => 'tenant_not_assigned']; - } - - // Verify tenant exists - $tenant = TenantRepository::find($tenantId); - if (!$tenant) { - return ['ok' => false, 'error' => 'tenant_not_found']; - } - if (($tenant['status'] ?? 'active') !== 'active') { - return ['ok' => false, 'error' => 'tenant_inactive']; - } - - // Update current_tenant_id - $updated = UserRepository::setCurrentTenant($userId, $tenantId); - - if (!$updated) { - return ['ok' => false, 'error' => 'update_failed']; - } - - return ['ok' => true, 'tenant' => $tenant]; - } -} diff --git a/lib/Service/User/UserServicesFactory.php b/lib/Service/User/UserServicesFactory.php new file mode 100644 index 0000000..511eb10 --- /dev/null +++ b/lib/Service/User/UserServicesFactory.php @@ -0,0 +1,219 @@ +userAccountService ??= new UserAccountService( + $this->createUserReadRepository(), + $this->createUserWriteRepository(), + $this->createUserListQueryRepository(), + $this->createUserAssignmentService(), + $this->createUserPasswordService(), + $this->createUserSettingsGateway(), + $this->createUserScopeGateway() + ); + } + + public function createUserAssignmentService(): UserAssignmentService + { + return $this->userAssignmentService ??= new UserAssignmentService( + $this->createUserWriteRepository(), + $this->createUserTenantRepository(), + $this->createUserRoleRepository(), + $this->createUserDepartmentRepository(), + $this->createUserDirectoryGateway(), + $this->createUserPermissionGateway() + ); + } + + public function createUserTenantContextService(): UserTenantContextService + { + return $this->userTenantContextService ??= new UserTenantContextService( + $this->createUserReadRepository(), + $this->createUserWriteRepository(), + $this->createUserTenantRepository(), + $this->createUserScopeGateway(), + $this->createUserDirectoryGateway() + ); + } + + public function createUserPasswordService(): UserPasswordService + { + return $this->userPasswordService ??= new UserPasswordService( + $this->createUserPasswordPolicyService(), + $this->createUserWriteRepository() + ); + } + + public function createUserAvatarService(): UserAvatarService + { + return $this->userAvatarService ??= new UserAvatarService(); + } + + public function createUserSavedFilterService(): UserSavedFilterService + { + return $this->userSavedFilterService ??= new UserSavedFilterService( + $this->createUserSavedFilterRepository() + ); + } + + public function createUserReadRepository(): UserReadRepository + { + return $this->userReadRepository ??= new UserReadRepository(); + } + + public function createUserWriteRepository(): UserWriteRepository + { + return $this->userWriteRepository ??= new UserWriteRepository(); + } + + public function createUserListQueryRepository(): UserListQueryRepository + { + return $this->userListQueryRepository ??= new UserListQueryRepository(); + } + + public function createUserSavedFilterRepository(): UserSavedFilterRepository + { + return $this->userSavedFilterRepository ??= new UserSavedFilterRepository(); + } + + public function createUserTenantRepository(): UserTenantRepository + { + return $this->userTenantRepository ??= new UserTenantRepository(); + } + + public function createUserRoleRepository(): UserRoleRepository + { + return $this->userRoleRepository ??= new UserRoleRepository(); + } + + public function createUserDepartmentRepository(): UserDepartmentRepository + { + return $this->userDepartmentRepository ??= new UserDepartmentRepository(); + } + + public function createUserPasswordPolicyService(): UserPasswordPolicyService + { + return $this->userPasswordPolicyService ??= new UserPasswordPolicyService(); + } + + public function createUserSettingsGateway(): UserSettingsGateway + { + return $this->userSettingsGateway ??= new UserSettingsGateway($this->createSettingGateway()); + } + + public function createUserScopeGateway(): UserScopeGateway + { + return $this->userScopeGateway ??= new UserScopeGateway($this->tenantServicesFactory()->createTenantScopeService()); + } + + public function createUserDirectoryGateway(): UserDirectoryGateway + { + return $this->userDirectoryGateway ??= new UserDirectoryGateway(); + } + + public function createUserPermissionGateway(): UserPermissionGateway + { + return $this->userPermissionGateway ??= new UserPermissionGateway($this->createPermissionGateway()); + } + + private function createPermissionGateway(): PermissionGateway + { + return $this->permissionGateway ??= $this->accessServicesFactory()->createPermissionGateway(); + } + + private function accessServicesFactory(): AccessServicesFactory + { + return $this->accessServicesFactory ??= new AccessServicesFactory(); + } + + private function createSettingGateway(): SettingGateway + { + return $this->settingGateway ??= $this->settingServicesFactory()->createSettingGateway(); + } + + private function settingServicesFactory(): SettingServicesFactory + { + return $this->settingServicesFactory ??= new SettingServicesFactory(); + } + + private function tenantServicesFactory(): TenantServicesFactory + { + return $this->tenantServicesFactory ??= new TenantServicesFactory(); + } + + public function createUserLifecycleService(): UserLifecycleService + { + return $this->userLifecycleService ??= new UserLifecycleService( + $this->createUserReadRepository(), + $this->createUserWriteRepository(), + $this->createUserSettingsGateway(), + $this->createUserLifecycleAuditService() + ); + } + + public function createUserLifecycleRestoreService(): UserLifecycleRestoreService + { + return $this->userLifecycleRestoreService ??= new UserLifecycleRestoreService( + $this->createUserReadRepository(), + $this->createUserWriteRepository(), + $this->createUserLifecycleAuditService() + ); + } + + private function createUserLifecycleAuditService(): UserLifecycleAuditService + { + return $this->userLifecycleAuditService ??= $this->auditServicesFactory()->createUserLifecycleAuditService(); + } + + private function auditServicesFactory(): AuditServicesFactory + { + return $this->auditServicesFactory ??= new AuditServicesFactory(); + } +} diff --git a/lib/Service/User/UserSettingsGateway.php b/lib/Service/User/UserSettingsGateway.php new file mode 100644 index 0000000..b37658c --- /dev/null +++ b/lib/Service/User/UserSettingsGateway.php @@ -0,0 +1,42 @@ +settingGateway->getDefaultTenantId(); + } + + public function getDefaultRoleId(): ?int + { + return $this->settingGateway->getDefaultRoleId(); + } + + public function getDefaultDepartmentId(): ?int + { + return $this->settingGateway->getDefaultDepartmentId(); + } + + public function getAppTheme(): ?string + { + return $this->settingGateway->getAppTheme(); + } + + public function getUserInactivityDeactivateDays(): int + { + return $this->settingGateway->getUserInactivityDeactivateDays(); + } + + public function getUserInactivityDeleteDays(): int + { + return $this->settingGateway->getUserInactivityDeleteDays(); + } +} diff --git a/lib/Service/User/UserTenantContextService.php b/lib/Service/User/UserTenantContextService.php new file mode 100644 index 0000000..65415fc --- /dev/null +++ b/lib/Service/User/UserTenantContextService.php @@ -0,0 +1,204 @@ +userReadRepository->find($userId); + if (!$user) { + return null; + } + + $activeTenantIds = $this->scopeGateway->getUserTenantIds($userId); + if (!$activeTenantIds) { + return null; + } + + $currentTenantId = (int) ($user['current_tenant_id'] ?? 0); + if ($currentTenantId > 0 && in_array($currentTenantId, $activeTenantIds, true)) { + return $currentTenantId; + } + + $primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0); + if ($primaryTenantId > 0 && in_array($primaryTenantId, $activeTenantIds, true)) { + return $primaryTenantId; + } + + return $activeTenantIds[0] ?? null; + } + + /** + * Get the current tenant data for a user + */ + public function getCurrentTenant(int $userId): ?array + { + $currentTenantId = $this->getCurrentTenantId($userId); + if (!$currentTenantId) { + return null; + } + + return $this->directoryGateway->findTenant($currentTenantId); + } + + /** + * Get all tenants the user has access to + */ + public function getAvailableTenants(int $userId): array + { + $tenantIds = $this->scopeGateway->getUserTenantIds($userId); + if (!$tenantIds) { + return []; + } + + $tenants = []; + foreach ($tenantIds as $tenantId) { + $tenant = $this->directoryGateway->findTenant((int) $tenantId); + if ($tenant && (($tenant['status'] ?? 'active') === 'active')) { + $tenants[] = $tenant; + } + } + + usort($tenants, static function ($a, $b) { + return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? '')); + }); + + return $tenants; + } + + /** + * Get only explicitly assigned active tenants for a user. + * This intentionally ignores global tenant-scope bypass permissions. + */ + public function getAssignedActiveTenants(int $userId): array + { + if ($userId <= 0) { + return []; + } + + $tenantIds = array_values(array_unique(array_map( + 'intval', + $this->userTenantRepository->listTenantIdsByUserId($userId) + ))); + $tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0)); + if (!$tenantIds) { + return []; + } + + $activeTenantIds = $this->directoryGateway->listActiveTenantIdsByIds($tenantIds); + if (!$activeTenantIds) { + return []; + } + + $tenantsById = []; + foreach ($this->directoryGateway->listTenantsByIds($activeTenantIds) as $tenant) { + $tenantId = (int) ($tenant['id'] ?? 0); + if ($tenantId <= 0) { + continue; + } + $tenantsById[$tenantId] = $tenant; + } + + $tenants = []; + foreach ($tenantIds as $tenantId) { + if (isset($tenantsById[$tenantId])) { + $tenants[] = $tenantsById[$tenantId]; + } + } + + usort($tenants, static function ($a, $b) { + return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? '')); + }); + + return $tenants; + } + + /** + * Get available departments grouped by tenant for a user. + * Returns an array of groups: ['tenant' => [...], 'departments' => [...]] + */ + public function getAvailableDepartmentsByTenant(int $userId): array + { + $tenants = $this->getAvailableTenants($userId); + if (!$tenants) { + return []; + } + + $groups = []; + foreach ($tenants as $tenant) { + $tenantId = (int) ($tenant['id'] ?? 0); + if ($tenantId <= 0) { + continue; + } + $departments = $this->directoryGateway->listDepartmentsByTenantIds([$tenantId]); + usort($departments, static function ($a, $b) { + return strcmp((string) ($a['description'] ?? ''), (string) ($b['description'] ?? '')); + }); + $departments = array_map(static function (array $department): array { + return [ + 'id' => (int) ($department['id'] ?? 0), + 'uuid' => $department['uuid'] ?? '', + 'description' => $department['description'] ?? '', + ]; + }, $departments); + + $groups[] = [ + 'tenant' => [ + 'id' => (int) ($tenant['id'] ?? 0), + 'uuid' => $tenant['uuid'] ?? '', + 'description' => $tenant['description'] ?? '', + ], + 'departments' => $departments, + ]; + } + + return $groups; + } + + /** + * Set the current tenant for a user (with validation) + */ + public function setCurrentTenant(int $userId, int $tenantId): array + { + if ($tenantId <= 0) { + return ['ok' => false, 'error' => 'invalid_tenant']; + } + + $userTenantIds = $this->userTenantRepository->listTenantIdsByUserId($userId); + if (!in_array($tenantId, $userTenantIds, true)) { + return ['ok' => false, 'error' => 'tenant_not_assigned']; + } + + $tenant = $this->directoryGateway->findTenant($tenantId); + if (!$tenant) { + return ['ok' => false, 'error' => 'tenant_not_found']; + } + if (($tenant['status'] ?? 'active') !== 'active') { + return ['ok' => false, 'error' => 'tenant_inactive']; + } + + $updated = $this->userWriteRepository->setCurrentTenant($userId, $tenantId); + if (!$updated) { + return ['ok' => false, 'error' => 'update_failed']; + } + + return ['ok' => true, 'tenant' => $tenant]; + } +} diff --git a/lib/Support/Guard.php b/lib/Support/Guard.php index eb9a55e..8122ad1 100644 --- a/lib/Support/Guard.php +++ b/lib/Support/Guard.php @@ -2,15 +2,18 @@ namespace MintyPHP\Support; -use MintyPHP\Router; -use MintyPHP\Service\Access\PermissionService; -use MintyPHP\Service\Tenant\TenantService; -use MintyPHP\Service\Auth\AuthService; use MintyPHP\Http\Request; +use MintyPHP\Router; +use MintyPHP\Service\Access\AccessServicesFactory; +use MintyPHP\Service\Access\PermissionGateway; +use MintyPHP\Service\Auth\AuthServicesFactory; +use MintyPHP\Service\Directory\DirectoryServicesFactory; class Guard { private const TENANT_REFRESH_INTERVAL = 60; + private static ?AccessServicesFactory $accessServicesFactory = null; + private static ?PermissionGateway $permissionGateway = null; public static function requireLogin(string $redirect = 'login'): void { @@ -20,7 +23,7 @@ class Guard } self::refreshTenantContext(); if (!empty($_SESSION['no_active_tenant'])) { - AuthService::logout(); + (new AuthServicesFactory())->createAuthService()->logout(); Flash::error('No active tenant assigned', 'login', 'no_active_tenant'); Router::redirect($redirect); } @@ -34,7 +37,7 @@ class Guard } $tenantId = (int) $currentTenant['id']; - $tenant = TenantService::findById($tenantId); + $tenant = (new DirectoryServicesFactory())->createTenantService()->findById($tenantId); if ($tenant) { return; } @@ -42,7 +45,7 @@ class Guard // Current tenant was deleted, refresh session data $userId = (int) ($_SESSION['user']['id'] ?? 0); if ($userId > 0) { - AuthService::loadTenantDataIntoSession($userId); + (new AuthServicesFactory())->createAuthService()->loadTenantDataIntoSession($userId); } else { unset($_SESSION['current_tenant']); } @@ -59,7 +62,7 @@ class Guard if ($last > 0 && ($now - $last) < self::TENANT_REFRESH_INTERVAL) { return; } - AuthService::loadTenantDataIntoSession($userId); + (new AuthServicesFactory())->createAuthService()->loadTenantDataIntoSession($userId); $_SESSION['tenant_context_refreshed_at'] = $now; } @@ -68,7 +71,7 @@ class Guard self::requireLogin(); self::validateCurrentTenant(); $userId = (int) ($_SESSION['user']['id'] ?? 0); - if (!$userId || !PermissionService::userHas($userId, $permissionKey)) { + if (!$userId || !self::permissionGateway()->userHas($userId, $permissionKey)) { Flash::error('Permission denied', $redirect, 'permission_denied'); Router::redirect($redirect); } @@ -79,7 +82,7 @@ class Guard self::requireLogin(); self::validateCurrentTenant(); $userId = (int) ($_SESSION['user']['id'] ?? 0); - if (!$userId || !PermissionService::userHas($userId, $permissionKey)) { + if (!$userId || !self::permissionGateway()->userHas($userId, $permissionKey)) { if (Request::wantsJson()) { http_response_code(403); header('Content-Type: application/json; charset=utf-8'); @@ -89,4 +92,24 @@ class Guard Router::redirect('error/forbidden?url=' . urlencode(Request::pathWithQuery())); } } + + private static function permissionGateway(): PermissionGateway + { + if (self::$permissionGateway instanceof PermissionGateway) { + return self::$permissionGateway; + } + + self::$permissionGateway = self::accessServicesFactory()->createPermissionGateway(); + return self::$permissionGateway; + } + + private static function accessServicesFactory(): AccessServicesFactory + { + if (self::$accessServicesFactory instanceof AccessServicesFactory) { + return self::$accessServicesFactory; + } + + self::$accessServicesFactory = new AccessServicesFactory(); + return self::$accessServicesFactory; + } } diff --git a/lib/Support/Search/SearchItemMapperProvider.php b/lib/Support/Search/SearchItemMapperProvider.php index abecaf0..366223a 100644 --- a/lib/Support/Search/SearchItemMapperProvider.php +++ b/lib/Support/Search/SearchItemMapperProvider.php @@ -3,9 +3,12 @@ namespace MintyPHP\Support\Search; use MintyPHP\Service\User\UserAvatarService; +use MintyPHP\Service\User\UserServicesFactory; class SearchItemMapperProvider { + private static ?UserAvatarService $userAvatarService = null; + public static function mapPreviewItem(string $key, array $data, string $query): ?array { if ($key === 'users') { @@ -87,7 +90,7 @@ class SearchItemMapperProvider $description = (string) ($data['email'] ?? ''); $uuid = (string) ($data['uuid'] ?? ''); $url = lurl('address-book/view/' . $uuid); - if ($uuid !== '' && UserAvatarService::hasAvatar($uuid)) { + if ($uuid !== '' && self::userAvatarService()->hasAvatar($uuid)) { $image = lurl('admin/users/avatar-file') . '?uuid=' . urlencode($uuid) . '&size=64'; } } elseif ($key === 'permissions') { @@ -144,4 +147,14 @@ class SearchItemMapperProvider 'icon' => SearchUiMetaProvider::iconForKey($key), ]; } + + private static function userAvatarService(): UserAvatarService + { + if (self::$userAvatarService instanceof UserAvatarService) { + return self::$userAvatarService; + } + + self::$userAvatarService = (new UserServicesFactory())->createUserAvatarService(); + return self::$userAvatarService; + } } diff --git a/lib/Support/helpers/app.php b/lib/Support/helpers/app.php index 8fd2abe..64ccfee 100644 --- a/lib/Support/helpers/app.php +++ b/lib/Support/helpers/app.php @@ -127,10 +127,72 @@ function appLogoUrlAbsolute(int $size = 128): string */ function appSetting(string $key): ?string { - if (!class_exists('MintyPHP\\Service\\Settings\\SettingCacheService')) { + if (!class_exists('MintyPHP\\Service\\Settings\\SettingServicesFactory')) { return null; } - return \MintyPHP\Service\Settings\SettingCacheService::get($key); + + static $settingsFactory = null; + if (!$settingsFactory instanceof \MintyPHP\Service\Settings\SettingServicesFactory) { + $settingsFactory = new \MintyPHP\Service\Settings\SettingServicesFactory(); + } + + return $settingsFactory->createSettingCacheService()->get($key); +} + +/** + * Shared per-request factory for tenant/department/role domain services. + */ +function directoryServicesFactory(): \MintyPHP\Service\Directory\DirectoryServicesFactory +{ + static $factory = null; + if (!$factory instanceof \MintyPHP\Service\Directory\DirectoryServicesFactory) { + $factory = new \MintyPHP\Service\Directory\DirectoryServicesFactory(); + } + return $factory; +} + +/** + * Shared per-request factory for access/permission services. + */ +function accessServicesFactory(): \MintyPHP\Service\Access\AccessServicesFactory +{ + static $factory = null; + if (!$factory instanceof \MintyPHP\Service\Access\AccessServicesFactory) { + $factory = new \MintyPHP\Service\Access\AccessServicesFactory(); + } + return $factory; +} + +/** + * Shared per-request permission gateway wrapper. + */ +function permissionGateway(): \MintyPHP\Service\Access\PermissionGateway +{ + return accessServicesFactory()->createPermissionGateway(); +} + +/** + * Shared per-request factory for audit services. + */ +function auditServicesFactory(): \MintyPHP\Service\Audit\AuditServicesFactory +{ + static $factory = null; + if (!$factory instanceof \MintyPHP\Service\Audit\AuditServicesFactory) { + $factory = new \MintyPHP\Service\Audit\AuditServicesFactory(); + } + return $factory; +} + +/** + * Shared per-request factory for branding services. + */ +function brandingServicesFactory(): \MintyPHP\Service\Branding\BrandingServicesFactory +{ + static $factory = null; + if (!$factory instanceof \MintyPHP\Service\Branding\BrandingServicesFactory) { + $factory = new \MintyPHP\Service\Branding\BrandingServicesFactory(); + } + return $factory; } /** @@ -138,13 +200,19 @@ function appSetting(string $key): ?string */ function appThemes(): array { - if (!class_exists('MintyPHP\\Service\\Settings\\ThemeConfigService')) { + if (!class_exists('MintyPHP\\Service\\Settings\\SettingServicesFactory')) { return [ 'light' => 'Light', 'dark' => 'Dark', ]; } - return \MintyPHP\Service\Settings\ThemeConfigService::all(); + + static $settingsFactory = null; + if (!$settingsFactory instanceof \MintyPHP\Service\Settings\SettingServicesFactory) { + $settingsFactory = new \MintyPHP\Service\Settings\SettingServicesFactory(); + } + + return $settingsFactory->createThemeConfigService()->all(); } /** @@ -325,9 +393,12 @@ function appPrimaryCssVars(): string */ function appLogoUrl(?int $size = null): string { - if (class_exists('MintyPHP\\Service\\Branding\\BrandingLogoService') && \MintyPHP\Service\Branding\BrandingLogoService::hasLogo()) { - $query = $size ? '?size=' . (int) $size : ''; - return lurl('branding/logo' . $query); + if (class_exists('MintyPHP\\Service\\Branding\\BrandingServicesFactory')) { + $logoService = brandingServicesFactory()->createBrandingLogoService(); + if ($logoService->hasLogo()) { + $query = $size ? '?size=' . (int) $size : ''; + return lurl('branding/logo' . $query); + } } return asset('brand/logo.svg'); } @@ -338,8 +409,13 @@ function appLogoUrl(?int $size = null): string function appFaviconUrl(string $file): string { $tenantUuid = $_SESSION['current_tenant']['uuid'] ?? ''; - if ($tenantUuid !== '' && class_exists('MintyPHP\\Service\\Tenant\\TenantFaviconService')) { - if (\MintyPHP\Service\Tenant\TenantFaviconService::hasFavicon($tenantUuid)) { + if ($tenantUuid !== '' && class_exists('MintyPHP\\Service\\Tenant\\TenantServicesFactory')) { + static $tenantServicesFactory = null; + if (!$tenantServicesFactory instanceof \MintyPHP\Service\Tenant\TenantServicesFactory) { + $tenantServicesFactory = new \MintyPHP\Service\Tenant\TenantServicesFactory(); + } + + if ($tenantServicesFactory->createTenantFaviconService()->hasFavicon($tenantUuid)) { return asset('favicon/tenants/' . $tenantUuid . '/favicon/' . ltrim($file, '/')); } } diff --git a/lib/Support/helpers/auth.php b/lib/Support/helpers/auth.php index f15ac99..939691f 100644 --- a/lib/Support/helpers/auth.php +++ b/lib/Support/helpers/auth.php @@ -19,13 +19,16 @@ function can(string $permissionKey): bool if ($userId <= 0) { return false; } - if (!class_exists('MintyPHP\\Service\\Access\\PermissionService')) { + if (!function_exists('permissionGateway')) { return false; } + + $permissionGateway = permissionGateway(); + // First try the in-memory/session cache, then fall back to a direct fetch. - $keys = \MintyPHP\Service\Access\PermissionService::getCachedPermissions($userId); + $keys = $permissionGateway->getCachedPermissions($userId); if (!$keys) { - $keys = \MintyPHP\Service\Access\PermissionService::getUserPermissions($userId); + $keys = $permissionGateway->getUserPermissions($userId); if (!$keys) { return false; } diff --git a/pages/address-book/data().php b/pages/address-book/data().php index 2c70d74..49eaf8d 100644 --- a/pages/address-book/data().php +++ b/pages/address-book/data().php @@ -1,103 +1,13 @@ $limit, - 'offset' => $offset, - 'search' => $search, - 'order' => $order, - 'dir' => $dir, - 'tenant' => $tenant, - 'tenants' => $tenants, - 'departments' => $departments, - 'roles' => $roles, - 'customFieldFilterSpec' => $customFieldFilterSpec, - 'tenantUserId' => $currentUserId, - 'active' => 'active', -]); - -$rows = []; -foreach ($result['rows'] as $row) { - $tenantLabels = $row['tenant_labels'] ?? []; - if (is_string($tenantLabels)) { - $tenantList = $tenantLabels !== '' - ? array_values(array_filter(array_map('trim', explode('||', $tenantLabels)))) - : []; - } elseif (is_array($tenantLabels)) { - $tenantList = array_values(array_filter(array_map('trim', $tenantLabels))); - } else { - $tenantList = []; - } - - $departmentLabels = $row['department_labels'] ?? []; - if (is_string($departmentLabels)) { - $departmentList = $departmentLabels !== '' - ? array_values(array_filter(array_map('trim', explode('||', $departmentLabels)))) - : []; - } elseif (is_array($departmentLabels)) { - $departmentList = array_values(array_filter(array_map('trim', $departmentLabels))); - } else { - $departmentList = []; - } - - $roleLabels = $row['role_labels'] ?? []; - if (is_string($roleLabels)) { - $roleList = $roleLabels !== '' - ? array_values(array_filter(array_map('trim', explode('||', $roleLabels)))) - : []; - } elseif (is_array($roleLabels)) { - $roleList = array_values(array_filter(array_map('trim', $roleLabels))); - } else { - $roleList = []; - } - - $uuid = (string) ($row['uuid'] ?? ''); - $displayName = trim((string) ($row['display_name'] ?? '')); - if ($displayName === '') { - $displayName = trim((string) ($row['first_name'] ?? '') . ' ' . (string) ($row['last_name'] ?? '')); - } - if ($displayName === '') { - $displayName = (string) ($row['email'] ?? ''); - } - $rows[] = [ - 'uuid' => $uuid, - 'display_name' => $displayName, - 'first_name' => $row['first_name'] ?? '', - 'last_name' => $row['last_name'] ?? '', - 'email' => $row['email'] ?? '', - 'phone' => $row['phone'] ?? '', - 'mobile' => $row['mobile'] ?? '', - 'short_dial' => $row['short_dial'] ?? '', - 'tenants' => $tenantList, - 'departments' => $departmentList, - 'roles' => $roleList, - 'has_avatar' => $uuid !== '' && UserAvatarService::hasAvatar($uuid), - ]; -} - -Router::json([ - 'data' => $rows, - 'total' => $result['total'] ?? 0, -]); +$addressBookService = (new AddressBookServicesFactory())->createAddressBookService(); +Router::json($addressBookService->buildGridResult($currentUserId, $_GET)); diff --git a/pages/address-book/index().php b/pages/address-book/index().php index 7a7a670..9f2e495 100644 --- a/pages/address-book/index().php +++ b/pages/address-book/index().php @@ -1,110 +1,26 @@ 0 && in_array($id, $tenantIds, true); - })); -} elseif (TenantScopeService::isStrict()) { - $tenants = []; -} -$tenantItems = array_map( - static fn (array $tenant): array => [ - 'id' => (string) ($tenant['uuid'] ?? ''), - 'description' => $tenant['description'] ?? '', - ], - $tenants -); - -$departments = $tenantIds - ? DepartmentService::listByTenantIds($tenantIds) - : (TenantScopeService::isStrict() ? [] : DepartmentService::list()); - -$roles = RoleService::listActive(); -$customFieldFilterSpec = UserCustomFieldValueService::extractAddressBookFilterSpec($_GET, $currentUserId); -$customFieldFilterDefinitions = $customFieldFilterSpec['definitions'] ?? []; -$customFieldFilterQuery = $customFieldFilterSpec['query'] ?? []; -$customFieldDefinitionIds = []; -foreach ($customFieldFilterDefinitions as $definition) { - $definitionId = (int) ($definition['id'] ?? 0); - if ($definitionId > 0) { - $customFieldDefinitionIds[] = $definitionId; - } -} -$customFieldDefinitionIds = array_values(array_unique($customFieldDefinitionIds)); -$customFieldOptions = TenantCustomFieldOptionRepository::listByDefinitionIds($customFieldDefinitionIds, true); -$customFieldOptionsByDefinition = []; -foreach ($customFieldOptions as $option) { - $definitionId = (int) ($option['definition_id'] ?? 0); - if ($definitionId <= 0) { - continue; - } - $customFieldOptionsByDefinition[$definitionId] ??= []; - $customFieldOptionsByDefinition[$definitionId][] = [ - 'id' => (string) ((int) ($option['id'] ?? 0)), - 'description' => (string) ($option['label'] ?? ''), - ]; -} -$tenantLabelById = []; -foreach ($tenants as $tenant) { - $tenantId = (int) ($tenant['id'] ?? 0); - if ($tenantId <= 0) { - continue; - } - $tenantLabelById[$tenantId] = (string) ($tenant['description'] ?? ''); -} -foreach ($customFieldFilterDefinitions as &$definition) { - $definitionId = (int) ($definition['id'] ?? 0); - $tenantId = (int) ($definition['tenant_id'] ?? 0); - $definition['options'] = $customFieldOptionsByDefinition[$definitionId] ?? []; - $definition['tenant_label'] = $tenantLabelById[$tenantId] ?? ''; -} -unset($definition); - -$tenantDepartmentMap = []; -if ($tenantIds) { - $departmentMapByTenantId = []; - foreach ($departments as $department) { - $departmentId = (int) ($department['id'] ?? 0); - $departmentTenantId = (int) ($department['tenant_id'] ?? 0); - if ($departmentId <= 0 || $departmentTenantId <= 0) { - continue; - } - $departmentMapByTenantId[$departmentTenantId] ??= []; - $departmentMapByTenantId[$departmentTenantId][] = $departmentId; - } - foreach ($tenants as $tenant) { - $tenantId = (int) ($tenant['id'] ?? 0); - $tenantUuid = (string) ($tenant['uuid'] ?? ''); - if ($tenantId > 0 && $tenantUuid !== '') { - $tenantDepartmentMap[$tenantUuid] = array_map( - 'strval', - $departmentMapByTenantId[$tenantId] ?? [] - ); - } - } -} +$addressBookService = (new AddressBookServicesFactory())->createAddressBookService(); +$context = $addressBookService->buildIndexContext($currentUserId, $_GET); +$activeTenants = $context['activeTenants'] ?? []; +$activeRoles = $context['activeRoles'] ?? []; +$activeDepartments = $context['activeDepartments'] ?? []; +$tenantItems = $context['tenantItems'] ?? []; +$departments = $context['departments'] ?? []; +$roles = $context['roles'] ?? []; +$customFieldFilterDefinitions = $context['customFieldFilterDefinitions'] ?? []; +$customFieldFilterQuery = $context['customFieldFilterQuery'] ?? []; +$tenantDepartmentMap = $context['tenantDepartmentMap'] ?? []; $csrfKey = Session::$csrfSessionKey; $csrfToken = $_SESSION[$csrfKey] ?? ''; diff --git a/pages/address-book/saved-filter-delete().php b/pages/address-book/saved-filter-delete().php index f3f8894..510562b 100644 --- a/pages/address-book/saved-filter-delete().php +++ b/pages/address-book/saved-filter-delete().php @@ -1,11 +1,11 @@ createUserSavedFilterService(); $uuid = trim((string) ($_POST['uuid'] ?? '')); if ($uuid === '') { @@ -98,9 +99,9 @@ if ($uuid === '') { return; } -$deleted = UserSavedFilterService::deleteAddressBookFilter($userId, $uuid); +$deleted = $userSavedFilterService->deleteAddressBookFilter($userId, $uuid); if ($deleted) { - $_SESSION['address_book_saved_filters'] = UserSavedFilterService::listForAddressBook($userId); + $_SESSION['address_book_saved_filters'] = $userSavedFilterService->listForAddressBook($userId); Flash::success(t('Filter deleted'), 'address-book', 'address_book_saved_filter_deleted'); } diff --git a/pages/address-book/saved-filter-save().php b/pages/address-book/saved-filter-save().php index d560340..2f3bfca 100644 --- a/pages/address-book/saved-filter-save().php +++ b/pages/address-book/saved-filter-save().php @@ -1,11 +1,11 @@ createUserSavedFilterService(); $name = trim((string) ($_POST['name'] ?? '')); -$result = UserSavedFilterService::saveAddressBookFilter($userId, $name, $_POST); +$result = $userSavedFilterService->saveAddressBookFilter($userId, $name, $_POST); if (!($result['ok'] ?? false)) { $error = (string) ($result['error'] ?? ''); @@ -108,6 +109,6 @@ if (!($result['ok'] ?? false)) { return; } -$_SESSION['address_book_saved_filters'] = UserSavedFilterService::listForAddressBook($userId); +$_SESSION['address_book_saved_filters'] = $userSavedFilterService->listForAddressBook($userId); Flash::success(t('Filter saved'), 'address-book', 'address_book_saved_filter_saved'); Router::redirect($redirect); diff --git a/pages/address-book/view($id).php b/pages/address-book/view($id).php index e22a355..12d0f6e 100644 --- a/pages/address-book/view($id).php +++ b/pages/address-book/view($id).php @@ -1,13 +1,11 @@ createAddressBookService(); +$viewContext = $addressBookService->buildViewContext($currentUserId, $uuid); +$status = (string) ($viewContext['status'] ?? ''); +if ($status === 'not_found') { Flash::error('User not found', 'address-book', 'user_not_found'); Router::redirect('address-book'); } - -$userId = (int) ($user['id'] ?? 0); -if ($currentUserId !== $userId && !TenantScopeService::canAccess('users', $userId, $currentUserId)) { +if ($status === 'forbidden') { Router::redirect('error/forbidden'); } - -$getRowValue = static function (array $row, string $key): ?string { - foreach ($row as $value) { - if (is_array($value) && array_key_exists($key, $value)) { - return (string) $value[$key]; - } - } - if (array_key_exists($key, $row) && !is_array($row[$key])) { - return (string) $row[$key]; - } - return null; -}; - -$extractLabels = static function (array $rows): array { - $labels = []; - foreach ($rows as $row) { - $label = ''; - foreach ($row as $value) { - if (is_array($value) && isset($value['description'])) { - $label = (string) $value['description']; - break; - } - } - if ($label === '' && isset($row['description'])) { - $label = (string) $row['description']; - } - if ($label !== '') { - $labels[] = $label; - } - } - return array_values(array_unique($labels)); -}; - -$viewerTenantIds = array_values(array_unique(array_map( - 'intval', - TenantScopeService::getUserTenantIds($currentUserId) -))); -$viewerTenantIds = array_values(array_filter($viewerTenantIds, static fn ($id) => $id > 0)); - -$tenantRows = []; -$departmentRows = []; -if ($viewerTenantIds) { - $tenantPlaceholders = implode(',', array_fill(0, count($viewerTenantIds), '?')); - $tenantRows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge( - [ - 'select t.id as id, t.description as description ' . - 'from user_tenants ut ' . - "join tenants t on t.id = ut.tenant_id and t.status = 'active' " . - 'where ut.user_id = ? and ut.tenant_id in (' . $tenantPlaceholders . ') ' . - 'order by t.description asc', - ], - array_merge([(string) $userId], array_map('strval', $viewerTenantIds)) - )); - $departmentRows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge( - [ - 'select d.tenant_id as tenant_id, d.description as description ' . - 'from user_departments ud ' . - "join departments d on d.id = ud.department_id and d.active = 1 " . - 'where ud.user_id = ? and d.tenant_id in (' . $tenantPlaceholders . ') ' . - 'order by d.description asc', - ], - array_merge([(string) $userId], array_map('strval', $viewerTenantIds)) - )); +$user = $viewContext['user'] ?? null; +if (!is_array($user)) { + Router::redirect('address-book'); } -$roleRows = DB::select( - 'select r.description as description from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 where ur.user_id = ? order by r.description asc', - (string) $userId -); - -$departmentMap = []; -foreach (is_array($departmentRows) ? $departmentRows : [] as $row) { - $tenantId = (int) ($getRowValue($row, 'tenant_id') ?? 0); - $label = (string) ($getRowValue($row, 'description') ?? ''); - if ($tenantId > 0 && $label !== '') { - $departmentMap[$tenantId][] = $label; - } -} -foreach ($departmentMap as $tenantId => $labels) { - $departmentMap[$tenantId] = array_values(array_unique($labels)); -} - -$primaryTenantId = (int) ($user['primary_tenant_id'] ?? 0); -$tenantGroups = []; -foreach (is_array($tenantRows) ? $tenantRows : [] as $row) { - $tenantId = (int) ($getRowValue($row, 'id') ?? 0); - $tenantLabel = (string) ($getRowValue($row, 'description') ?? ''); - if ($tenantId <= 0 || $tenantLabel === '') { - continue; - } - $tenantGroups[] = [ - 'label' => $tenantLabel, - 'is_primary' => $primaryTenantId > 0 && $tenantId === $primaryTenantId, - 'departments' => $departmentMap[$tenantId] ?? [], - ]; -} - -$roleLabels = $extractLabels(is_array($roleRows) ? $roleRows : []); - -$user['tenant_groups'] = $tenantGroups; -$user['role_labels'] = $roleLabels; Buffer::set('style_groups', json_encode(['address-book'])); $name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? '')); diff --git a/pages/address-book/view(default).phtml b/pages/address-book/view(default).phtml index 3e1ef30..d6d2810 100644 --- a/pages/address-book/view(default).phtml +++ b/pages/address-book/view(default).phtml @@ -1,6 +1,5 @@ 'method_not_allowed']); } -$result = ApiAuditService::listPaged([ +$factory = new AuditServicesFactory(); +$result = $factory->createApiAuditService()->listPaged([ 'limit' => (int) ($_GET['limit'] ?? 20), 'offset' => (int) ($_GET['offset'] ?? 0), 'search' => trim((string) ($_GET['search'] ?? '')), @@ -69,4 +70,3 @@ Router::json([ 'data' => $rows, 'total' => (int) ($result['total'] ?? 0), ]); - diff --git a/pages/admin/api-audit/purge().php b/pages/admin/api-audit/purge().php index d493917..54326ba 100644 --- a/pages/admin/api-audit/purge().php +++ b/pages/admin/api-audit/purge().php @@ -4,8 +4,8 @@ use MintyPHP\Router; use MintyPHP\Session; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; -use MintyPHP\Service\Audit\ApiAuditService; use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Audit\AuditServicesFactory; Guard::requireLogin(); Guard::requirePermission(PermissionService::SETTINGS_UPDATE); @@ -18,6 +18,7 @@ if (!Session::checkCsrfToken()) { Router::redirect('admin/api-audit'); } -$deleted = ApiAuditService::purgeExpired(); +$factory = new AuditServicesFactory(); +$deleted = $factory->createApiAuditService()->purgeExpired(); Flash::success(sprintf(t('%d API audit entries purged'), $deleted), 'admin/api-audit', 'api_audit_purged'); Router::redirect('admin/api-audit'); diff --git a/pages/admin/api-audit/view($id).php b/pages/admin/api-audit/view($id).php index c98ac94..06e0984 100644 --- a/pages/admin/api-audit/view($id).php +++ b/pages/admin/api-audit/view($id).php @@ -4,18 +4,18 @@ use MintyPHP\Buffer; use MintyPHP\Router; use MintyPHP\Support\Flash; use MintyPHP\Support\Guard; -use MintyPHP\Service\Audit\ApiAuditService; use MintyPHP\Service\Access\PermissionService; +use MintyPHP\Service\Audit\AuditServicesFactory; Guard::requireLogin(); Guard::requirePermission(PermissionService::API_AUDIT_VIEW); $auditId = (int) ($id ?? 0); -$auditLog = $auditId > 0 ? ApiAuditService::find($auditId) : null; +$factory = new AuditServicesFactory(); +$auditLog = $auditId > 0 ? $factory->createApiAuditService()->find($auditId) : null; if (!$auditLog) { Flash::error('API audit entry not found', 'admin/api-audit', 'api_audit_not_found'); Router::redirect('admin/api-audit'); } Buffer::set('title', t('View API audit entry')); - diff --git a/pages/admin/departments/create().php b/pages/admin/departments/create().php index b9fafb1..3188fdf 100644 --- a/pages/admin/departments/create().php +++ b/pages/admin/departments/create().php @@ -1,20 +1,17 @@ createDirectoryScopeGateway()->getUserTenantIds($currentUserId); +if (directoryServicesFactory()->createDirectoryScopeGateway()->isStrict() && !$allowedTenantIds) { Router::redirect('error/forbidden'); return; } @@ -28,24 +25,24 @@ $form = [ 'cost_center' => '', 'active' => '1', ]; -$tenants = TenantService::list(); +$tenants = directoryServicesFactory()->createTenantService()->list(); if ($allowedTenantIds) { $tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool { $tenantId = (int) ($tenant['id'] ?? 0); return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true); })); -} elseif (TenantScopeService::isStrict()) { +} elseif (directoryServicesFactory()->createDirectoryScopeGateway()->isStrict()) { $tenants = []; } if (isset($_POST['description'])) { $selectedTenantId = (int) ($_POST['tenant_id'] ?? 0); - $selectedTenantIds = TenantScopeService::filterTenantIdsForUser([$selectedTenantId], $currentUserId); + $selectedTenantIds = directoryServicesFactory()->createDirectoryScopeGateway()->filterTenantIdsForUser([$selectedTenantId], $currentUserId); $selectedTenantId = (int) ($selectedTenantIds[0] ?? 0); $input = $_POST; $input['tenant_id'] = $selectedTenantId; - $result = DepartmentService::createFromAdmin($input, $currentUserId); + $result = directoryServicesFactory()->createDepartmentService()->createFromAdmin($input, $currentUserId); $form = $result['form'] ?? $form; $errors = $result['errors'] ?? []; $warnings = $result['warnings'] ?? []; diff --git a/pages/admin/departments/data().php b/pages/admin/departments/data().php index fe402b4..512e18b 100644 --- a/pages/admin/departments/data().php +++ b/pages/admin/departments/data().php @@ -1,18 +1,16 @@ createDirectoryScopeGateway()->getUserTenantIds($currentUserId); $limit = (int) ($_GET['limit'] ?? 10); $offset = (int) ($_GET['offset'] ?? 0); @@ -22,7 +20,7 @@ $dir = (string) ($_GET['dir'] ?? 'asc'); $tenant = trim((string) ($_GET['tenant'] ?? '')); $active = trim((string) ($_GET['active'] ?? '')); -$result = DepartmentService::listPaged([ +$result = directoryServicesFactory()->createDepartmentService()->listPaged([ 'limit' => $limit, 'offset' => $offset, 'search' => $search, @@ -34,7 +32,9 @@ $result = DepartmentService::listPaged([ 'tenantUserId' => $currentUserId, ]); -$defaultDepartmentId = SettingService::getDefaultDepartmentId(); +$settingsFactory = new SettingServicesFactory(); +$settingGateway = $settingsFactory->createSettingGateway(); +$defaultDepartmentId = $settingGateway->getDefaultDepartmentId(); $departmentIds = []; foreach ($result['rows'] as $departmentRow) { $departmentId = (int) ($departmentRow['id'] ?? 0); @@ -43,8 +43,9 @@ foreach ($result['rows'] as $departmentRow) { } } $departmentIds = array_values(array_unique($departmentIds)); -$departmentUserCounts = UserDepartmentRepository::countUsersByDepartmentIds($departmentIds); -$departmentActiveUserCounts = UserDepartmentRepository::countActiveUsersByDepartmentIds($departmentIds); +$userDepartmentRepository = (new UserServicesFactory())->createUserDepartmentRepository(); +$departmentUserCounts = $userDepartmentRepository->countUsersByDepartmentIds($departmentIds); +$departmentActiveUserCounts = $userDepartmentRepository->countActiveUsersByDepartmentIds($departmentIds); $rows = []; foreach ($result['rows'] as $row) { diff --git a/pages/admin/departments/delete($id).php b/pages/admin/departments/delete($id).php index 5def2c7..6c1cbbc 100644 --- a/pages/admin/departments/delete($id).php +++ b/pages/admin/departments/delete($id).php @@ -1,12 +1,10 @@ createDepartmentService()->findByUuid($uuid) : null; +if ($department && !directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('departments', (int) ($department['id'] ?? 0), $currentUserId)) { Router::redirect('error/forbidden'); return; } -$result = DepartmentService::deleteByUuid($uuid); +$result = directoryServicesFactory()->createDepartmentService()->deleteByUuid($uuid); if (!($result['ok'] ?? false)) { Flash::error('Department not found', 'admin/departments', 'department_not_found'); Router::redirect('admin/departments'); } if ($currentUserId > 0) { - AuthService::loadTenantDataIntoSession($currentUserId); + (new AuthServicesFactory())->createAuthService()->loadTenantDataIntoSession($currentUserId); } Flash::success('Department deleted', 'admin/departments', 'department_deleted'); diff --git a/pages/admin/departments/edit($id).php b/pages/admin/departments/edit($id).php index 8cfe489..4a4b29d 100644 --- a/pages/admin/departments/edit($id).php +++ b/pages/admin/departments/edit($id).php @@ -1,39 +1,37 @@ 0) { - PermissionService::getUserPermissions($currentUserId); + permissionGateway()->getUserPermissions($currentUserId); } -$allowedTenantIds = TenantScopeService::getUserTenantIds($currentUserId); +$userAccountService = (new UserServicesFactory())->createUserAccountService(); +$allowedTenantIds = directoryServicesFactory()->createDirectoryScopeGateway()->getUserTenantIds($currentUserId); $uuid = trim((string) ($id ?? '')); -$department = $uuid !== '' ? DepartmentService::findByUuid($uuid) : null; +$department = $uuid !== '' ? directoryServicesFactory()->createDepartmentService()->findByUuid($uuid) : null; if (!$department) { Flash::error('Department not found', 'admin/departments', 'department_not_found'); Router::redirect('admin/departments'); } $departmentId = (int) ($department['id'] ?? 0); -if (!TenantScopeService::canAccess('departments', $departmentId, $currentUserId)) { +if (!directoryServicesFactory()->createDirectoryScopeGateway()->canAccess('departments', $departmentId, $currentUserId)) { Router::redirect('error/forbidden'); return; } $creatorId = (int) ($department['created_by'] ?? 0); if ($creatorId > 0) { - $creator = UserService::findById($creatorId); + $creator = $userAccountService->findById($creatorId); if ($creator) { $creatorName = trim(($creator['first_name'] ?? '') . ' ' . ($creator['last_name'] ?? '')); $department['created_by_label'] = $creatorName !== '' ? $creatorName : ($creator['email'] ?? ''); @@ -42,7 +40,7 @@ if ($creatorId > 0) { } $modifierId = (int) ($department['modified_by'] ?? 0); if ($modifierId > 0) { - $modifier = UserService::findById($modifierId); + $modifier = $userAccountService->findById($modifierId); if ($modifier) { $modifierName = trim(($modifier['first_name'] ?? '') . ' ' . ($modifier['last_name'] ?? '')); $department['modified_by_label'] = $modifierName !== '' ? $modifierName : ($modifier['email'] ?? ''); @@ -53,7 +51,7 @@ if ($modifierId > 0) { $errors = []; $warnings = []; $form = $department; -$tenants = TenantService::list(); +$tenants = directoryServicesFactory()->createTenantService()->list(); if ($allowedTenantIds) { $tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool { $tenantId = (int) ($tenant['id'] ?? 0); @@ -62,30 +60,30 @@ if ($allowedTenantIds) { if (!in_array((int) ($form['tenant_id'] ?? 0), $allowedTenantIds, true)) { $form['tenant_id'] = 0; } -} elseif (TenantScopeService::isStrict()) { +} elseif (directoryServicesFactory()->createDirectoryScopeGateway()->isStrict()) { $tenants = []; $form['tenant_id'] = 0; } if (isset($_POST['description'])) { - if (!PermissionService::userHas($currentUserId, PermissionService::DEPARTMENTS_UPDATE)) { + if (!permissionGateway()->userHas($currentUserId, PermissionService::DEPARTMENTS_UPDATE)) { Router::redirect('error/forbidden'); return; } $selectedTenantId = (int) ($_POST['tenant_id'] ?? 0); - $selectedTenantIds = TenantScopeService::filterTenantIdsForUser([$selectedTenantId], $currentUserId); + $selectedTenantIds = directoryServicesFactory()->createDirectoryScopeGateway()->filterTenantIdsForUser([$selectedTenantId], $currentUserId); $selectedTenantId = (int) ($selectedTenantIds[0] ?? 0); $input = $_POST; $input['tenant_id'] = $selectedTenantId; - $result = DepartmentService::updateFromAdmin($departmentId, $input, $currentUserId); + $result = directoryServicesFactory()->createDepartmentService()->updateFromAdmin($departmentId, $input, $currentUserId); $form = $result['form'] ?? $form; $errors = $result['errors'] ?? []; $warnings = $result['warnings'] ?? []; $form['tenant_id'] = $selectedTenantId; if (($result['ok'] ?? false) && !$errors) { - $cleaned = DepartmentService::cleanupUserAssignments($departmentId); + $cleaned = directoryServicesFactory()->createDepartmentService()->cleanupUserAssignments($departmentId); $action = (string) ($_POST['action'] ?? 'save'); if ($cleaned > 0) { Flash::info(t('Department assignments cleaned: %d', $cleaned), "admin/departments/edit/{$uuid}", 'department_assignments_cleaned'); diff --git a/pages/admin/departments/index().php b/pages/admin/departments/index().php index 9fca460..dd17a2d 100644 --- a/pages/admin/departments/index().php +++ b/pages/admin/departments/index().php @@ -1,28 +1,26 @@ 0) { - PermissionService::getUserPermissions($currentUserId); + permissionGateway()->getUserPermissions($currentUserId); } -$tenants = TenantService::list(); -$allowedTenantIds = TenantScopeService::getUserTenantIds($currentUserId); +$tenants = directoryServicesFactory()->createTenantService()->list(); +$allowedTenantIds = directoryServicesFactory()->createDirectoryScopeGateway()->getUserTenantIds($currentUserId); if ($allowedTenantIds) { $tenants = array_values(array_filter($tenants, static function (array $tenant) use ($allowedTenantIds): bool { $tenantId = (int) ($tenant['id'] ?? 0); return $tenantId > 0 && in_array($tenantId, $allowedTenantIds, true); })); -} elseif (TenantScopeService::isStrict()) { +} elseif (directoryServicesFactory()->createDirectoryScopeGateway()->isStrict()) { $tenants = []; } usort($tenants, static function ($a, $b) { diff --git a/pages/admin/docs/index(default).phtml b/pages/admin/docs/index(default).phtml index 41c56d7..7165142 100644 --- a/pages/admin/docs/index(default).phtml +++ b/pages/admin/docs/index(default).phtml @@ -12,22 +12,31 @@