instances added god may help

This commit is contained in:
2026-02-23 12:58:19 +01:00
parent 25370a1a55
commit 99db252f60
290 changed files with 9858 additions and 4914 deletions

View File

@@ -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));

View File

@@ -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.

View 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).

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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`

View File

@@ -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).

View File

@@ -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

View File

@@ -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:

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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)) {

View File

@@ -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;

View File

@@ -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)) {

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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())'

View File

@@ -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 = ?',

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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)) {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 = ?',

View File

@@ -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`), '

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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)) {

View File

@@ -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())',

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

View 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));
}
}

View File

@@ -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));
}
}

View File

@@ -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);

View 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;
}
}

View 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());
}
}

View 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);
}
}

View File

@@ -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;

View File

@@ -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()
);
}
}

View 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);
}
}

View 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);
}
}

View 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();
}
}

View 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 !== ''));
}
}

View 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();
}
}

View File

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

View 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()
);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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();
}
}

View File

@@ -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];

View 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();
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View File

@@ -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);

View File

@@ -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));

View 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;
}
}

View 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);
}
}

View File

@@ -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);

View File

@@ -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;
}
}
}

View File

@@ -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'];

View File

@@ -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 === '') {

View File

@@ -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';
}

View File

@@ -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) {

View 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();
}
}

View File

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

View File

@@ -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' => []];

View 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();
}
}

View 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();
}
}

View 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);
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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');

View File

@@ -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));
}
}

View 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();
}
}

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

View File

@@ -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) {

View 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();
}
}

View File

@@ -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

View 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);
}
}

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View 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();
}
}

View File

@@ -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();
}
}

View File

@@ -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