instances added god may help
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
78
docs/auth-sso-smoke-test.md
Normal file
78
docs/auth-sso-smoke-test.md
Normal file
@@ -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=<slug>`).
|
||||
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=<slug>` 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/<uuid>` ö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).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/<modul>/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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 <token>`
|
||||
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
|
||||
|
||||
@@ -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=<slug>` 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:
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())'
|
||||
|
||||
@@ -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 = ?',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = ?',
|
||||
|
||||
@@ -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`), '
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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())',
|
||||
|
||||
384
lib/Repository/User/UserListQueryRepository.php
Normal file
384
lib/Repository/User/UserListQueryRepository.php
Normal file
@@ -0,0 +1,384 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
class UserListQueryRepository
|
||||
{
|
||||
private function buildUserFilters(array $options): array
|
||||
{
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$active = $options['active'] ?? null;
|
||||
$createdFrom = trim((string) ($options['created_from'] ?? ''));
|
||||
$createdTo = trim((string) ($options['created_to'] ?? ''));
|
||||
$tenant = trim((string) ($options['tenant'] ?? ''));
|
||||
$tenantUuids = array_filter(array_map('trim', explode(',', (string) ($options['tenants'] ?? ''))));
|
||||
$roleIds = RepoQuery::normalizeIdList($options['roles'] ?? []);
|
||||
$departmentIds = RepoQuery::normalizeIdList($options['departments'] ?? []);
|
||||
$emailVerified = $options['email_verified'] ?? null;
|
||||
$loginStatus = $options['login_status'] ?? null;
|
||||
$customFieldFilterSpec = is_array($options['customFieldFilterSpec'] ?? null)
|
||||
? $options['customFieldFilterSpec']
|
||||
: [];
|
||||
$customFieldFilters = is_array($customFieldFilterSpec['filters'] ?? null)
|
||||
? $customFieldFilterSpec['filters']
|
||||
: [];
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
RepoQuery::addLikeFilter(
|
||||
$where,
|
||||
$params,
|
||||
['users.first_name', 'users.last_name', 'users.email'],
|
||||
$search
|
||||
);
|
||||
RepoQuery::addEnumFilter($where, $params, $active, [
|
||||
['aliases' => ['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,
|
||||
];
|
||||
}
|
||||
}
|
||||
154
lib/Repository/User/UserReadRepository.php
Normal file
154
lib/Repository/User/UserReadRepository.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class UserReadRepository
|
||||
{
|
||||
public 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 $this->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));
|
||||
}
|
||||
}
|
||||
@@ -1,841 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
class UserRepository
|
||||
{
|
||||
private static function buildUserFilters(array $options): array
|
||||
{
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$active = $options['active'] ?? null;
|
||||
$createdFrom = trim((string) ($options['created_from'] ?? ''));
|
||||
$createdTo = trim((string) ($options['created_to'] ?? ''));
|
||||
$tenant = trim((string) ($options['tenant'] ?? ''));
|
||||
$tenantUuids = array_filter(array_map('trim', explode(',', (string) ($options['tenants'] ?? ''))));
|
||||
$roleIds = RepoQuery::normalizeIdList($options['roles'] ?? []);
|
||||
$departmentIds = RepoQuery::normalizeIdList($options['departments'] ?? []);
|
||||
$emailVerified = $options['email_verified'] ?? null;
|
||||
$loginStatus = $options['login_status'] ?? null;
|
||||
$customFieldFilterSpec = is_array($options['customFieldFilterSpec'] ?? null)
|
||||
? $options['customFieldFilterSpec']
|
||||
: [];
|
||||
$customFieldFilters = is_array($customFieldFilterSpec['filters'] ?? null)
|
||||
? $customFieldFilterSpec['filters']
|
||||
: [];
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
RepoQuery::addLikeFilter(
|
||||
$where,
|
||||
$params,
|
||||
['users.first_name', 'users.last_name', 'users.email'],
|
||||
$search
|
||||
);
|
||||
RepoQuery::addEnumFilter($where, $params, $active, [
|
||||
['aliases' => ['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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
319
lib/Repository/User/UserWriteRepository.php
Normal file
319
lib/Repository/User/UserWriteRepository.php
Normal file
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
class UserWriteRepository
|
||||
{
|
||||
public 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 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 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 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;
|
||||
}
|
||||
}
|
||||
45
lib/Service/Access/AccessServicesFactory.php
Normal file
45
lib/Service/Access/AccessServicesFactory.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Access;
|
||||
|
||||
use MintyPHP\Repository\Access\PermissionRepository;
|
||||
use MintyPHP\Repository\Access\RolePermissionRepository;
|
||||
use MintyPHP\Repository\Access\UserRoleRepository;
|
||||
|
||||
class AccessServicesFactory
|
||||
{
|
||||
private ?PermissionRepository $permissionRepository = null;
|
||||
private ?RolePermissionRepository $rolePermissionRepository = null;
|
||||
private ?UserRoleRepository $userRoleRepository = null;
|
||||
private ?PermissionService $permissionService = null;
|
||||
private ?PermissionGateway $permissionGateway = null;
|
||||
|
||||
public function createPermissionRepository(): PermissionRepository
|
||||
{
|
||||
return $this->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());
|
||||
}
|
||||
}
|
||||
55
lib/Service/Access/PermissionGateway.php
Normal file
55
lib/Service/Access/PermissionGateway.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Access;
|
||||
|
||||
class PermissionGateway
|
||||
{
|
||||
public function __construct(private readonly PermissionService $permissionService)
|
||||
{
|
||||
}
|
||||
|
||||
public function userHas(int $userId, string $permissionKey): bool
|
||||
{
|
||||
return $this->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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
17
lib/Service/AddressBook/AddressBookAvatarGateway.php
Normal file
17
lib/Service/AddressBook/AddressBookAvatarGateway.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\AddressBook;
|
||||
|
||||
use MintyPHP\Service\User\UserAvatarService;
|
||||
|
||||
class AddressBookAvatarGateway
|
||||
{
|
||||
public function __construct(private readonly UserAvatarService $userAvatarService)
|
||||
{
|
||||
}
|
||||
|
||||
public function hasAvatar(string $userUuid): bool
|
||||
{
|
||||
return $this->userAvatarService->hasAvatar($userUuid);
|
||||
}
|
||||
}
|
||||
19
lib/Service/AddressBook/AddressBookCustomFieldGateway.php
Normal file
19
lib/Service/AddressBook/AddressBookCustomFieldGateway.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\AddressBook;
|
||||
|
||||
use MintyPHP\Repository\CustomField\TenantCustomFieldOptionRepository;
|
||||
use MintyPHP\Service\CustomField\UserCustomFieldValueService;
|
||||
|
||||
class AddressBookCustomFieldGateway
|
||||
{
|
||||
public function extractFilterSpec(array $query, int $currentUserId): array
|
||||
{
|
||||
return UserCustomFieldValueService::extractAddressBookFilterSpec($query, $currentUserId);
|
||||
}
|
||||
|
||||
public function listOptionsByDefinitionIds(array $definitionIds, bool $activeOnly = true): array
|
||||
{
|
||||
return TenantCustomFieldOptionRepository::listByDefinitionIds($definitionIds, $activeOnly);
|
||||
}
|
||||
}
|
||||
74
lib/Service/AddressBook/AddressBookDirectoryGateway.php
Normal file
74
lib/Service/AddressBook/AddressBookDirectoryGateway.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\AddressBook;
|
||||
|
||||
use MintyPHP\Service\Directory\DirectoryScopeGateway;
|
||||
use MintyPHP\Service\Access\RoleService;
|
||||
use MintyPHP\Service\Org\DepartmentService;
|
||||
use MintyPHP\Service\Tenant\TenantService;
|
||||
|
||||
class AddressBookDirectoryGateway
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ?TenantService $tenantService = null,
|
||||
private readonly ?DepartmentService $departmentService = null,
|
||||
private readonly ?RoleService $roleService = null,
|
||||
private readonly ?DirectoryScopeGateway $scopeGateway = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function listTenants(): array
|
||||
{
|
||||
return $this->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();
|
||||
}
|
||||
}
|
||||
349
lib/Service/AddressBook/AddressBookService.php
Normal file
349
lib/Service/AddressBook/AddressBookService.php
Normal file
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\AddressBook;
|
||||
|
||||
use MintyPHP\Service\User\UserAccountService;
|
||||
use MintyPHP\Service\User\UserAssignmentService;
|
||||
|
||||
class AddressBookService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserAccountService $userAccountService,
|
||||
private readonly UserAssignmentService $userAssignmentService,
|
||||
private readonly AddressBookDirectoryGateway $directoryGateway,
|
||||
private readonly AddressBookCustomFieldGateway $customFieldGateway,
|
||||
private readonly AddressBookAvatarGateway $avatarGateway
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildIndexContext(int $currentUserId, array $query): array
|
||||
{
|
||||
$activeTenants = $this->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 !== ''));
|
||||
}
|
||||
}
|
||||
69
lib/Service/AddressBook/AddressBookServicesFactory.php
Normal file
69
lib/Service/AddressBook/AddressBookServicesFactory.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\AddressBook;
|
||||
|
||||
use MintyPHP\Service\Directory\DirectoryServicesFactory;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
|
||||
class AddressBookServicesFactory
|
||||
{
|
||||
private ?AddressBookDirectoryGateway $addressBookDirectoryGateway = null;
|
||||
private ?AddressBookCustomFieldGateway $addressBookCustomFieldGateway = null;
|
||||
private ?AddressBookAvatarGateway $addressBookAvatarGateway = null;
|
||||
private ?AddressBookService $addressBookService = null;
|
||||
private ?UserServicesFactory $userServicesFactory = null;
|
||||
private ?DirectoryServicesFactory $directoryServicesFactory = null;
|
||||
|
||||
public function createAddressBookService(): AddressBookService
|
||||
{
|
||||
if ($this->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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
36
lib/Service/Audit/AuditServicesFactory.php
Normal file
36
lib/Service/Audit/AuditServicesFactory.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Audit;
|
||||
|
||||
use MintyPHP\Repository\Audit\ApiAuditLogRepository;
|
||||
use MintyPHP\Repository\Audit\UserLifecycleAuditRepository;
|
||||
|
||||
class AuditServicesFactory
|
||||
{
|
||||
private ?ApiAuditLogRepository $apiAuditLogRepository = null;
|
||||
private ?UserLifecycleAuditRepository $userLifecycleAuditRepository = null;
|
||||
private ?ApiAuditService $apiAuditService = null;
|
||||
private ?UserLifecycleAuditService $userLifecycleAuditService = null;
|
||||
|
||||
public function createApiAuditLogRepository(): ApiAuditLogRepository
|
||||
{
|
||||
return $this->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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,13 @@ class ImportAuditService
|
||||
/**
|
||||
* @var array<int, float>
|
||||
*/
|
||||
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<int, string> $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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
22
lib/Service/Auth/AuthAvatarGateway.php
Normal file
22
lib/Service/Auth/AuthAvatarGateway.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Service\User\UserAvatarService;
|
||||
|
||||
class AuthAvatarGateway
|
||||
{
|
||||
public function __construct(private readonly UserAvatarService $userAvatarService)
|
||||
{
|
||||
}
|
||||
|
||||
public function isValidUuid(string $uuid): bool
|
||||
{
|
||||
return $this->userAvatarService->isValidUuid($uuid);
|
||||
}
|
||||
|
||||
public function saveBinary(string $uuid, string $binary, string $mime): void
|
||||
{
|
||||
$this->userAvatarService->saveBinary($uuid, $binary, $mime);
|
||||
}
|
||||
}
|
||||
23
lib/Service/Auth/AuthCryptoGateway.php
Normal file
23
lib/Service/Auth/AuthCryptoGateway.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Support\Crypto;
|
||||
|
||||
class AuthCryptoGateway
|
||||
{
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return Crypto::isConfigured();
|
||||
}
|
||||
|
||||
public function encryptString(string $value): string
|
||||
{
|
||||
return Crypto::encryptString($value);
|
||||
}
|
||||
|
||||
public function decryptString(string $value): string
|
||||
{
|
||||
return Crypto::decryptString($value);
|
||||
}
|
||||
}
|
||||
27
lib/Service/Auth/AuthExternalIdentityGateway.php
Normal file
27
lib/Service/Auth/AuthExternalIdentityGateway.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Repository\User\UserExternalIdentityRepository;
|
||||
|
||||
class AuthExternalIdentityGateway
|
||||
{
|
||||
public function __construct(private readonly UserExternalIdentityRepository $userExternalIdentityRepository)
|
||||
{
|
||||
}
|
||||
|
||||
public function findByProviderTidOid(string $provider, string $tid, string $oid): ?array
|
||||
{
|
||||
return $this->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);
|
||||
}
|
||||
}
|
||||
22
lib/Service/Auth/AuthPermissionGateway.php
Normal file
22
lib/Service/Auth/AuthPermissionGateway.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Service\Access\PermissionGateway;
|
||||
|
||||
class AuthPermissionGateway
|
||||
{
|
||||
public function __construct(private readonly PermissionGateway $permissionGateway)
|
||||
{
|
||||
}
|
||||
|
||||
public function warmUserPermissions(int $userId, bool $forceRefresh = true): array
|
||||
{
|
||||
return $this->permissionGateway->getUserPermissions($userId, $forceRefresh);
|
||||
}
|
||||
|
||||
public function userHas(int $userId, string $permissionKey): bool
|
||||
{
|
||||
return $this->permissionGateway->userHas($userId, $permissionKey);
|
||||
}
|
||||
}
|
||||
17
lib/Service/Auth/AuthSavedFilterGateway.php
Normal file
17
lib/Service/Auth/AuthSavedFilterGateway.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Service\User\UserSavedFilterService;
|
||||
|
||||
class AuthSavedFilterGateway
|
||||
{
|
||||
public function __construct(private readonly UserSavedFilterService $userSavedFilterService)
|
||||
{
|
||||
}
|
||||
|
||||
public function listAddressBookFilters(int $userId): array
|
||||
{
|
||||
return $this->userSavedFilterService->listForAddressBook($userId);
|
||||
}
|
||||
}
|
||||
42
lib/Service/Auth/AuthScopeGateway.php
Normal file
42
lib/Service/Auth/AuthScopeGateway.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
use MintyPHP\Service\Tenant\TenantServicesFactory;
|
||||
|
||||
class AuthScopeGateway
|
||||
{
|
||||
public function __construct(private readonly ?TenantScopeService $tenantScopeService = null)
|
||||
{
|
||||
}
|
||||
|
||||
public function hasGlobalAccess(int $userId): bool
|
||||
{
|
||||
return $this->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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
271
lib/Service/Auth/AuthServicesFactory.php
Normal file
271
lib/Service/Auth/AuthServicesFactory.php
Normal file
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepository;
|
||||
use MintyPHP\Repository\Auth\EmailVerificationRepository;
|
||||
use MintyPHP\Repository\Auth\PasswordResetRepository;
|
||||
use MintyPHP\Repository\Auth\RememberTokenRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepository;
|
||||
use MintyPHP\Repository\User\UserExternalIdentityRepository;
|
||||
use MintyPHP\Service\Access\AccessServicesFactory;
|
||||
use MintyPHP\Service\Access\PermissionGateway;
|
||||
use MintyPHP\Service\Mail\MailServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Tenant\TenantServicesFactory;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
|
||||
class AuthServicesFactory
|
||||
{
|
||||
private ?AccessServicesFactory $accessServicesFactory = null;
|
||||
private ?TenantServicesFactory $tenantServicesFactory = null;
|
||||
private ?UserServicesFactory $userServicesFactory = null;
|
||||
private ?MailServicesFactory $mailServicesFactory = null;
|
||||
private ?SettingServicesFactory $settingServicesFactory = null;
|
||||
private ?AuthSettingsGateway $authSettingsGateway = null;
|
||||
private ?SettingGateway $settingGateway = null;
|
||||
private ?AuthScopeGateway $authScopeGateway = null;
|
||||
private ?AuthPermissionGateway $authPermissionGateway = null;
|
||||
private ?PermissionGateway $permissionGateway = null;
|
||||
private ?AuthTenantGateway $authTenantGateway = null;
|
||||
private ?AuthCryptoGateway $authCryptoGateway = null;
|
||||
private ?AuthTenantSsoGateway $authTenantSsoGateway = null;
|
||||
private ?AuthSavedFilterGateway $authSavedFilterGateway = null;
|
||||
private ?AuthAvatarGateway $authAvatarGateway = null;
|
||||
private ?AuthExternalIdentityGateway $authExternalIdentityGateway = null;
|
||||
private ?ApiTokenRepository $apiTokenRepository = null;
|
||||
private ?RememberTokenRepository $rememberTokenRepository = null;
|
||||
private ?PasswordResetRepository $passwordResetRepository = null;
|
||||
private ?EmailVerificationRepository $emailVerificationRepository = null;
|
||||
private ?TenantMicrosoftAuthRepository $tenantMicrosoftAuthRepository = null;
|
||||
private ?UserExternalIdentityRepository $userExternalIdentityRepository = null;
|
||||
private ?TenantSsoService $tenantSsoService = null;
|
||||
private ?OidcHttpGateway $oidcHttpGateway = null;
|
||||
private ?MicrosoftOidcStateStoreService $microsoftOidcStateStoreService = null;
|
||||
private ?MicrosoftOidcService $microsoftOidcService = null;
|
||||
private ?ApiTokenService $apiTokenService = null;
|
||||
private ?PasswordResetService $passwordResetService = null;
|
||||
private ?EmailVerificationService $emailVerificationService = null;
|
||||
private ?RememberMeService $rememberMeService = null;
|
||||
private ?AuthService $authService = null;
|
||||
private ?SsoUserLinkService $ssoUserLinkService = null;
|
||||
|
||||
public function createApiTokenService(): ApiTokenService
|
||||
{
|
||||
return $this->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();
|
||||
}
|
||||
}
|
||||
52
lib/Service/Auth/AuthSettingsGateway.php
Normal file
52
lib/Service/Auth/AuthSettingsGateway.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
|
||||
class AuthSettingsGateway
|
||||
{
|
||||
public function __construct(private readonly SettingGateway $settingGateway)
|
||||
{
|
||||
}
|
||||
|
||||
public function getMicrosoftSharedClientId(): string
|
||||
{
|
||||
return (string) $this->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();
|
||||
}
|
||||
}
|
||||
23
lib/Service/Auth/AuthTenantGateway.php
Normal file
23
lib/Service/Auth/AuthTenantGateway.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
|
||||
class AuthTenantGateway
|
||||
{
|
||||
public function findById(int $tenantId): ?array
|
||||
{
|
||||
return (new TenantRepository())->find($tenantId);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?array
|
||||
{
|
||||
return (new TenantRepository())->findByUuid($uuid);
|
||||
}
|
||||
|
||||
public function list(): array
|
||||
{
|
||||
return (new TenantRepository())->list();
|
||||
}
|
||||
}
|
||||
30
lib/Service/Auth/AuthTenantSsoGateway.php
Normal file
30
lib/Service/Auth/AuthTenantSsoGateway.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
class AuthTenantSsoGateway
|
||||
{
|
||||
public function __construct(private readonly TenantSsoService $tenantSsoService)
|
||||
{
|
||||
}
|
||||
|
||||
public function isLocalPasswordLoginAllowed(int $tenantId): bool
|
||||
{
|
||||
return $this->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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
94
lib/Service/Auth/MicrosoftOidcStateStoreService.php
Normal file
94
lib/Service/Auth/MicrosoftOidcStateStoreService.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
class MicrosoftOidcStateStoreService
|
||||
{
|
||||
private const SESSION_KEY = 'microsoft_oidc_states';
|
||||
|
||||
public function __construct(
|
||||
private readonly int $ttlSeconds = 600,
|
||||
private readonly int $maxEntries = 50
|
||||
) {
|
||||
}
|
||||
|
||||
public function store(string $state, array $payload): void
|
||||
{
|
||||
$state = trim($state);
|
||||
if ($state === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$states = $this->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;
|
||||
}
|
||||
}
|
||||
16
lib/Service/Auth/OidcHttpGateway.php
Normal file
16
lib/Service/Auth/OidcHttpGateway.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Auth;
|
||||
|
||||
use MintyPHP\Curl;
|
||||
|
||||
class OidcHttpGateway
|
||||
{
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
public function call(string $method, string $url, mixed $data = '', array $headers = []): array
|
||||
{
|
||||
return Curl::call($method, $url, $data, $headers);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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 === '') {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
34
lib/Service/Branding/BrandingServicesFactory.php
Normal file
34
lib/Service/Branding/BrandingServicesFactory.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Branding;
|
||||
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
|
||||
class BrandingServicesFactory
|
||||
{
|
||||
private ?SettingServicesFactory $settingServicesFactory = null;
|
||||
private ?SettingGateway $settingGateway = null;
|
||||
private ?BrandingLogoService $brandingLogoService = null;
|
||||
private ?BrandingFaviconService $brandingFaviconService = null;
|
||||
|
||||
public function createBrandingLogoService(): BrandingLogoService
|
||||
{
|
||||
return $this->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();
|
||||
}
|
||||
}
|
||||
@@ -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')]];
|
||||
}
|
||||
|
||||
|
||||
@@ -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<int, array<string, mixed>> $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<int, array<string, mixed>>
|
||||
*/
|
||||
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' => []];
|
||||
|
||||
64
lib/Service/Directory/DirectoryScopeGateway.php
Normal file
64
lib/Service/Directory/DirectoryScopeGateway.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Directory;
|
||||
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
use MintyPHP\Service\Tenant\TenantServicesFactory;
|
||||
|
||||
class DirectoryScopeGateway
|
||||
{
|
||||
public function __construct(private readonly ?TenantScopeService $tenantScopeService = null)
|
||||
{
|
||||
}
|
||||
|
||||
public function hasGlobalAccess(int $userId): bool
|
||||
{
|
||||
return $this->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();
|
||||
}
|
||||
}
|
||||
99
lib/Service/Directory/DirectoryServicesFactory.php
Normal file
99
lib/Service/Directory/DirectoryServicesFactory.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Directory;
|
||||
|
||||
use MintyPHP\Repository\Access\RoleRepository;
|
||||
use MintyPHP\Repository\Org\DepartmentRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
use MintyPHP\Service\Access\RoleService;
|
||||
use MintyPHP\Service\Org\DepartmentService;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Tenant\TenantServicesFactory;
|
||||
use MintyPHP\Service\Tenant\TenantService;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
|
||||
class DirectoryServicesFactory
|
||||
{
|
||||
private ?TenantRepository $tenantRepository = null;
|
||||
private ?DepartmentRepository $departmentRepository = null;
|
||||
private ?RoleRepository $roleRepository = null;
|
||||
private ?DirectorySettingsGateway $directorySettingsGateway = null;
|
||||
private ?DirectoryScopeGateway $directoryScopeGateway = null;
|
||||
private ?TenantServicesFactory $tenantServicesFactory = null;
|
||||
private ?TenantService $tenantService = null;
|
||||
private ?DepartmentService $departmentService = null;
|
||||
private ?RoleService $roleService = null;
|
||||
private ?UserServicesFactory $userServicesFactory = null;
|
||||
private ?SettingServicesFactory $settingServicesFactory = null;
|
||||
|
||||
public function createTenantService(): TenantService
|
||||
{
|
||||
return $this->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();
|
||||
}
|
||||
}
|
||||
27
lib/Service/Directory/DirectorySettingsGateway.php
Normal file
27
lib/Service/Directory/DirectorySettingsGateway.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Directory;
|
||||
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
|
||||
class DirectorySettingsGateway
|
||||
{
|
||||
public function __construct(private readonly SettingGateway $settingGateway)
|
||||
{
|
||||
}
|
||||
|
||||
public function setDefaultTenantId(?int $tenantId): void
|
||||
{
|
||||
$this->settingGateway->setDefaultTenantId($tenantId);
|
||||
}
|
||||
|
||||
public function setDefaultDepartmentId(?int $departmentId): void
|
||||
{
|
||||
$this->settingGateway->setDefaultDepartmentId($departmentId);
|
||||
}
|
||||
|
||||
public function setDefaultRoleId(?int $roleId): void
|
||||
{
|
||||
$this->settingGateway->setDefaultRoleId($roleId);
|
||||
}
|
||||
}
|
||||
@@ -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 "<h{$level}{$attrs}>{$html}</h{$level}>";
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,14 +9,14 @@ class CsvReaderService
|
||||
/**
|
||||
* @return array{ok:bool, delimiter?:string, headers?:array<int,string>, 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<int, string> $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<int,string>, 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<int, mixed> $row
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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<int, string> $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');
|
||||
|
||||
@@ -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<string, ImportProfileInterface>
|
||||
* @param array<string, ImportProfileInterface> $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<string, ImportProfileInterface>
|
||||
*/
|
||||
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<int, array{key:string,label:string}>
|
||||
*/
|
||||
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<string, mixed> $upload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $mappingInput
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $mappingInput
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed>|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<string, string> $mapping
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<int, string> $headers
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, string> $rowAssoc
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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<int, string> $headers
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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<int, string> $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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $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<int, array{row_number:int,identifier:string,code:string,message:string}> $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<string, int> $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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
106
lib/Service/Import/ImportServicesFactory.php
Normal file
106
lib/Service/Import/ImportServicesFactory.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Import;
|
||||
|
||||
use MintyPHP\Repository\Audit\ImportAuditRunRepository;
|
||||
use MintyPHP\Service\Access\AccessServicesFactory;
|
||||
use MintyPHP\Service\Access\PermissionGateway;
|
||||
use MintyPHP\Service\Audit\ImportAuditService;
|
||||
use MintyPHP\Service\Import\Profile\DepartmentImportGateway;
|
||||
use MintyPHP\Service\Import\Profile\DepartmentImportProfile;
|
||||
use MintyPHP\Service\Import\Profile\UserImportGateway;
|
||||
use MintyPHP\Service\Import\Profile\UserImportProfile;
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
|
||||
class ImportServicesFactory
|
||||
{
|
||||
private ?AccessServicesFactory $accessServicesFactory = null;
|
||||
private ?CsvReaderService $csvReaderService = null;
|
||||
private ?ImportTempFileService $importTempFileService = null;
|
||||
private ?ImportStateStoreService $importStateStoreService = null;
|
||||
private ?ImportAuditRunRepository $importAuditRunRepository = null;
|
||||
private ?ImportAuditService $importAuditService = null;
|
||||
private ?PermissionGateway $permissionGateway = null;
|
||||
private ?SettingServicesFactory $settingServicesFactory = null;
|
||||
private ?SettingGateway $settingGateway = null;
|
||||
private ?ImportService $importService = null;
|
||||
private ?UserServicesFactory $userServicesFactory = null;
|
||||
|
||||
public function createImportService(): ImportService
|
||||
{
|
||||
if ($this->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();
|
||||
}
|
||||
}
|
||||
63
lib/Service/Import/ImportStateStoreService.php
Normal file
63
lib/Service/Import/ImportStateStoreService.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Import;
|
||||
|
||||
class ImportStateStoreService
|
||||
{
|
||||
private const SESSION_KEY = 'admin_imports_v1';
|
||||
private const STATE_TTL_SECONDS = 7200;
|
||||
|
||||
public function __construct(private readonly ImportTempFileService $importTempFileService)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, mixed>|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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ class ImportTempFileService
|
||||
* @param array<string, mixed> $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) {
|
||||
|
||||
61
lib/Service/Import/Profile/DepartmentImportGateway.php
Normal file
61
lib/Service/Import/Profile/DepartmentImportGateway.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Import\Profile;
|
||||
|
||||
use MintyPHP\Repository\Org\DepartmentRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
use MintyPHP\Service\Org\DepartmentService;
|
||||
|
||||
class DepartmentImportGateway
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ?DepartmentRepository $departmentRepository = null,
|
||||
private readonly ?TenantRepository $tenantRepository = null,
|
||||
private readonly ?DepartmentService $departmentService = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function findTenantByUuid(string $uuid): ?array
|
||||
{
|
||||
return $this->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<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
62
lib/Service/Import/Profile/UserImportGateway.php
Normal file
62
lib/Service/Import/Profile/UserImportGateway.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Import\Profile;
|
||||
|
||||
use MintyPHP\Repository\Access\RoleRepository;
|
||||
use MintyPHP\Repository\Org\DepartmentRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
use MintyPHP\Repository\User\UserReadRepository;
|
||||
use MintyPHP\Service\User\UserAccountService;
|
||||
|
||||
class UserImportGateway
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserReadRepository $userReadRepository,
|
||||
private readonly UserAccountService $userAccountService
|
||||
) {
|
||||
}
|
||||
|
||||
public function findUserByEmail(string $email): ?array
|
||||
{
|
||||
return $this->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<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createUserFromAdmin(array $input, int $currentUserId): array
|
||||
{
|
||||
return $this->userAccountService->createFromAdmin($input, $currentUserId);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
44
lib/Service/Mail/MailServicesFactory.php
Normal file
44
lib/Service/Mail/MailServicesFactory.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Mail;
|
||||
|
||||
use MintyPHP\Repository\Mail\MailLogRepository;
|
||||
use MintyPHP\Service\Settings\SettingGateway;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
|
||||
class MailServicesFactory
|
||||
{
|
||||
private ?MailLogRepository $mailLogRepository = null;
|
||||
private ?SettingServicesFactory $settingServicesFactory = null;
|
||||
private ?SettingGateway $settingGateway = null;
|
||||
private ?MailService $mailService = null;
|
||||
private ?MailLogService $mailLogService = null;
|
||||
|
||||
public function createMailLogRepository(): MailLogRepository
|
||||
{
|
||||
return $this->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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user