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

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

View File

@@ -6,7 +6,11 @@ use MintyPHP\Service\User\UserLifecycleService;
class UserLifecycleJobHandler implements ScheduledJobHandlerInterface
{
public static function definition(): array
public function __construct(private readonly UserLifecycleService $userLifecycleService)
{
}
public function definition(): array
{
return [
'label' => 'User lifecycle run',
@@ -22,9 +26,9 @@ class UserLifecycleJobHandler implements ScheduledJobHandlerInterface
];
}
public static function execute(?int $actorUserId): array
public function execute(?int $actorUserId): array
{
$result = UserLifecycleService::run($actorUserId !== null && $actorUserId > 0 ? $actorUserId : null);
$result = $this->userLifecycleService->run($actorUserId !== null && $actorUserId > 0 ? $actorUserId : null);
if (!($result['ok'] ?? false)) {
$errorCode = (string) ($result['error'] ?? 'job_failed');
@@ -33,7 +37,7 @@ class UserLifecycleJobHandler implements ScheduledJobHandlerInterface
'status' => $status,
'error_code' => $errorCode,
'error_message' => null,
'result' => self::formatResult($result),
'result' => $this->formatResult($result),
];
}
@@ -41,11 +45,11 @@ class UserLifecycleJobHandler implements ScheduledJobHandlerInterface
'status' => 'success',
'error_code' => null,
'error_message' => null,
'result' => self::formatResult($result),
'result' => $this->formatResult($result),
];
}
private static function formatResult(array $result): array
private function formatResult(array $result): array
{
return [
'run_uuid' => (string) ($result['run_uuid'] ?? ''),

View File

@@ -4,7 +4,7 @@ namespace MintyPHP\Service\Scheduler;
class ScheduleCalculator
{
public static function normalizeTimezone(?string $timezone): string
public function normalizeTimezone(?string $timezone): string
{
$timezone = trim((string) $timezone);
if ($timezone === '') {
@@ -18,15 +18,15 @@ class ScheduleCalculator
}
}
public static function normalizeScheduleType(?string $type): string
public function normalizeScheduleType(?string $type): string
{
$type = strtolower(trim((string) $type));
return in_array($type, ['hourly', 'daily', 'weekly'], true) ? $type : 'daily';
}
public static function normalizeInterval(string $type, int $value): int
public function normalizeInterval(string $type, int $value): int
{
$type = self::normalizeScheduleType($type);
$type = $this->normalizeScheduleType($type);
if ($type === 'hourly') {
return max(1, min(24, $value));
}
@@ -36,7 +36,7 @@ class ScheduleCalculator
return max(1, min(365, $value));
}
public static function normalizeTime(?string $time): ?string
public function normalizeTime(?string $time): ?string
{
$time = trim((string) $time);
if ($time === '') {
@@ -53,9 +53,9 @@ class ScheduleCalculator
return sprintf('%02d:%02d', $hour, $minute);
}
public static function normalizeWeekdaysCsv(mixed $value): ?string
public function normalizeWeekdaysCsv(mixed $value): ?string
{
$list = self::parseWeekdays($value);
$list = $this->parseWeekdays($value);
if (!$list) {
return null;
}
@@ -65,7 +65,7 @@ class ScheduleCalculator
/**
* @return array<int>
*/
public static function parseWeekdays(mixed $value): array
public function parseWeekdays(mixed $value): array
{
$raw = [];
if (is_array($value)) {
@@ -104,12 +104,12 @@ class ScheduleCalculator
* Returns null if the schedule is misconfigured (e.g. missing schedule_time for daily/weekly)
* or if no valid next run could be found within the search window.
*/
public static function calculateNextRunUtc(array $job, ?\DateTimeImmutable $referenceUtc = null): ?\DateTimeImmutable
public function calculateNextRunUtc(array $job, ?\DateTimeImmutable $referenceUtc = null): ?\DateTimeImmutable
{
$referenceUtc = $referenceUtc ?? new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
$type = self::normalizeScheduleType((string) ($job['schedule_type'] ?? 'daily'));
$interval = self::normalizeInterval($type, (int) ($job['schedule_interval'] ?? 1));
$timezone = new \DateTimeZone(self::normalizeTimezone((string) ($job['timezone'] ?? '')));
$type = $this->normalizeScheduleType((string) ($job['schedule_type'] ?? 'daily'));
$interval = $this->normalizeInterval($type, (int) ($job['schedule_interval'] ?? 1));
$timezone = new \DateTimeZone($this->normalizeTimezone((string) ($job['timezone'] ?? '')));
$localReference = $referenceUtc->setTimezone($timezone);
if ($type === 'hourly') {
@@ -120,7 +120,7 @@ class ScheduleCalculator
return $candidate->setTimezone(new \DateTimeZone('UTC'));
}
$time = self::normalizeTime((string) ($job['schedule_time'] ?? ''));
$time = $this->normalizeTime((string) ($job['schedule_time'] ?? ''));
if ($time === null) {
return null;
}
@@ -138,7 +138,7 @@ class ScheduleCalculator
// week interval. The search window is 1110 days (~3 years), which comfortably covers
// the maximum weekly interval of 52 weeks × 7 days/week = 364 days, with margin for
// weekday alignment and DST edge cases.
$weekdays = self::parseWeekdays((string) ($job['schedule_weekdays_csv'] ?? ''));
$weekdays = $this->parseWeekdays((string) ($job['schedule_weekdays_csv'] ?? ''));
if (!$weekdays) {
$weekdays = [1];
}

View File

@@ -4,39 +4,42 @@ namespace MintyPHP\Service\Scheduler;
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
use MintyPHP\Service\Scheduler\Handler\UserLifecycleJobHandler;
use MintyPHP\Service\User\UserLifecycleService;
class ScheduledJobRegistry
{
public const USER_LIFECYCLE_RUN = 'user_lifecycle_run';
/**
* Maps job_key => handler class name (must implement ScheduledJobHandlerInterface).
* Maps job_key => handler instance (must implement ScheduledJobHandlerInterface).
*
* To register a new job:
* 1. Create lib/Service/Scheduler/Handler/YourJobHandler.php implementing the interface.
* 2. Add a public const for the job key above.
* 3. Add one line here: self::YOUR_JOB_KEY => YourJobHandler::class,
* 3. Add one line in __construct(): self::YOUR_JOB_KEY => new YourJobHandler(...),
*
* No other file needs to be changed.
*
* @return array<string, class-string<ScheduledJobHandlerInterface>>
* @var array<string, ScheduledJobHandlerInterface>
*/
private static function handlers(): array
private array $handlers;
public function __construct(UserLifecycleService $userLifecycleService)
{
return [
self::USER_LIFECYCLE_RUN => UserLifecycleJobHandler::class,
$this->handlers = [
self::USER_LIFECYCLE_RUN => new UserLifecycleJobHandler($userLifecycleService),
];
}
/**
* Returns all job definitions keyed by job_key.
* Consumed by ScheduledJobService::ensureSystemJobs() to sync registry with the database.
* Consumed by ScheduledJobService->ensureSystemJobs() to sync registry with the database.
*/
public static function definitions(): array
public function definitions(): array
{
$result = [];
foreach (self::handlers() as $jobKey => $handlerClass) {
$result[$jobKey] = $handlerClass::definition();
foreach ($this->handlers as $jobKey => $handler) {
$result[$jobKey] = $handler->definition();
}
return $result;
}
@@ -44,14 +47,13 @@ class ScheduledJobRegistry
/**
* Returns the definition for a single job key, or null if not registered.
*/
public static function get(string $jobKey): ?array
public function get(string $jobKey): ?array
{
$jobKey = trim($jobKey);
if ($jobKey === '') {
return null;
}
$handlers = self::handlers();
return isset($handlers[$jobKey]) ? $handlers[$jobKey]::definition() : null;
return isset($this->handlers[$jobKey]) ? $this->handlers[$jobKey]->definition() : null;
}
/**
@@ -63,10 +65,9 @@ class ScheduledJobRegistry
* @param int|null $actorUserId Null for scheduler-triggered runs; user ID for manual runs.
* @return array{status:string,error_code:?string,error_message:?string,result:array}
*/
public static function execute(string $jobKey, ?int $actorUserId): array
public function execute(string $jobKey, ?int $actorUserId): array
{
$handlers = self::handlers();
if (!isset($handlers[$jobKey])) {
if (!isset($this->handlers[$jobKey])) {
return [
'status' => 'failed',
'error_code' => 'job_not_supported',
@@ -74,6 +75,6 @@ class ScheduledJobRegistry
'result' => [],
];
}
return $handlers[$jobKey]::execute($actorUserId);
return $this->handlers[$jobKey]->execute($actorUserId);
}
}

View File

@@ -9,59 +9,67 @@ class ScheduledJobService
{
private const RUN_RETENTION_DAYS = 90;
public static function ensureSystemJobs(): void
public function __construct(
private readonly ScheduledJobRepository $scheduledJobRepository,
private readonly ScheduledJobRunRepository $scheduledJobRunRepository,
private readonly ScheduledJobRegistry $scheduledJobRegistry,
private readonly ScheduleCalculator $scheduleCalculator
) {
}
public function ensureSystemJobs(): void
{
$definitions = ScheduledJobRegistry::definitions();
$definitions = $this->scheduledJobRegistry->definitions();
$nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
foreach ($definitions as $jobKey => $definition) {
$existing = ScheduledJobRepository::findByKey($jobKey);
$job = self::buildDefaultJob($definition, $jobKey, $nowUtc);
$existing = $this->scheduledJobRepository->findByKey($jobKey);
$job = $this->buildDefaultJob($definition, $jobKey, $nowUtc);
if (!$existing) {
ScheduledJobRepository::create($job);
$this->scheduledJobRepository->create($job);
continue;
}
$normalized = self::normalizeStoredJob($existing, $definition);
if (!self::jobsDiffer($existing, $normalized)) {
$normalized = $this->normalizeStoredJob($existing, $definition);
if (!$this->jobsDiffer($existing, $normalized)) {
continue;
}
ScheduledJobRepository::updateJobMeta((int) $existing['id'], $normalized);
$this->scheduledJobRepository->updateJobMeta((int) $existing['id'], $normalized);
}
}
public static function listPaged(array $filters): array
public function listPaged(array $filters): array
{
self::ensureSystemJobs();
return ScheduledJobRepository::listPaged($filters);
$this->ensureSystemJobs();
return $this->scheduledJobRepository->listPaged($filters);
}
public static function find(int $id): ?array
public function find(int $id): ?array
{
self::ensureSystemJobs();
return ScheduledJobRepository::find($id);
$this->ensureSystemJobs();
return $this->scheduledJobRepository->find($id);
}
public static function updateFromAdmin(int $id, array $input): array
public function updateFromAdmin(int $id, array $input): array
{
$job = self::find($id);
$job = $this->find($id);
if (!$job) {
return ['ok' => false, 'errors' => [t('Scheduled job not found')]];
return ['ok' => false, 'errors' => [$this->translate('Scheduled job not found')]];
}
$definition = ScheduledJobRegistry::get((string) ($job['job_key'] ?? ''));
$definition = $this->scheduledJobRegistry->get((string) ($job['job_key'] ?? ''));
if (!$definition) {
return ['ok' => false, 'errors' => [t('Scheduled job is not registered')]];
return ['ok' => false, 'errors' => [$this->translate('Scheduled job is not registered')]];
}
$form = [
'enabled' => isset($input['enabled']) ? 1 : 0,
'timezone' => ScheduleCalculator::normalizeTimezone((string) ($input['timezone'] ?? ($job['timezone'] ?? ''))),
'schedule_type' => ScheduleCalculator::normalizeScheduleType((string) ($input['schedule_type'] ?? ($job['schedule_type'] ?? 'daily'))),
'timezone' => $this->scheduleCalculator->normalizeTimezone((string) ($input['timezone'] ?? ($job['timezone'] ?? ''))),
'schedule_type' => $this->scheduleCalculator->normalizeScheduleType((string) ($input['schedule_type'] ?? ($job['schedule_type'] ?? 'daily'))),
'schedule_interval' => (int) ($input['schedule_interval'] ?? ($job['schedule_interval'] ?? 1)),
'schedule_time' => ScheduleCalculator::normalizeTime((string) ($input['schedule_time'] ?? ($job['schedule_time'] ?? ''))),
'schedule_weekdays_csv' => ScheduleCalculator::normalizeWeekdaysCsv(
'schedule_time' => $this->scheduleCalculator->normalizeTime((string) ($input['schedule_time'] ?? ($job['schedule_time'] ?? ''))),
'schedule_weekdays_csv' => $this->scheduleCalculator->normalizeWeekdaysCsv(
array_key_exists('schedule_weekdays', $input)
? $input['schedule_weekdays']
: ($job['schedule_weekdays_csv'] ?? '')
@@ -69,18 +77,18 @@ class ScheduledJobService
'catchup_once' => isset($input['catchup_once']) ? 1 : 0,
];
$errors = self::validateForm($form, $definition);
$errors = $this->validateForm($form, $definition);
if ($errors) {
return ['ok' => false, 'errors' => $errors, 'form' => $form, 'job' => $job];
}
$nextRunAt = null;
if ((int) $form['enabled'] === 1) {
$nextRun = ScheduleCalculator::calculateNextRunUtc($form, new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
$nextRun = $this->scheduleCalculator->calculateNextRunUtc($form, new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
$nextRunAt = $nextRun ? $nextRun->format('Y-m-d H:i:s') : null;
}
$updated = ScheduledJobRepository::updateJobMeta((int) $job['id'], [
$updated = $this->scheduledJobRepository->updateJobMeta((int) $job['id'], [
'label' => (string) ($definition['label'] ?? (string) ($job['label'] ?? '')),
'description' => (string) ($definition['description'] ?? (string) ($job['description'] ?? '')),
'enabled' => (int) $form['enabled'],
@@ -94,95 +102,95 @@ class ScheduledJobService
]);
if (!$updated) {
return ['ok' => false, 'errors' => [t('Scheduled job could not be saved')], 'form' => $form, 'job' => $job];
return ['ok' => false, 'errors' => [$this->translate('Scheduled job could not be saved')], 'form' => $form, 'job' => $job];
}
$fresh = self::find((int) $job['id']);
$fresh = $this->find((int) $job['id']);
return ['ok' => true, 'job' => $fresh, 'form' => $form];
}
public static function listRunsByJobId(int $jobId, array $filters): array
public function listRunsByJobId(int $jobId, array $filters): array
{
return ScheduledJobRunRepository::listPagedByJobId($jobId, $filters);
return $this->scheduledJobRunRepository->listPagedByJobId($jobId, $filters);
}
public static function purgeRunsExpired(): int
public function purgeRunsExpired(): int
{
return ScheduledJobRunRepository::purgeOlderThanDays(self::RUN_RETENTION_DAYS);
return $this->scheduledJobRunRepository->purgeOlderThanDays(self::RUN_RETENTION_DAYS);
}
public static function scheduleSummary(array $job): string
public function scheduleSummary(array $job): string
{
$type = ScheduleCalculator::normalizeScheduleType((string) ($job['schedule_type'] ?? 'daily'));
$type = $this->scheduleCalculator->normalizeScheduleType((string) ($job['schedule_type'] ?? 'daily'));
$interval = (int) ($job['schedule_interval'] ?? 1);
$time = (string) ($job['schedule_time'] ?? '');
if ($type === 'hourly') {
return sprintf(t('Every %d hour(s)'), max(1, $interval));
return sprintf($this->translate('Every %d hour(s)'), max(1, $interval));
}
if ($type === 'daily') {
return sprintf(t('Every %d day(s) at %s'), max(1, $interval), $time !== '' ? $time : '--:--');
return sprintf($this->translate('Every %d day(s) at %s'), max(1, $interval), $time !== '' ? $time : '--:--');
}
$weekdays = ScheduleCalculator::parseWeekdays((string) ($job['schedule_weekdays_csv'] ?? ''));
$weekdays = $this->scheduleCalculator->parseWeekdays((string) ($job['schedule_weekdays_csv'] ?? ''));
$labels = [];
foreach ($weekdays as $day) {
$labels[] = self::weekdayLabel($day);
$labels[] = $this->weekdayLabel($day);
}
$weekdayText = $labels ? implode(', ', $labels) : t('No weekdays');
$weekdayText = $labels ? implode(', ', $labels) : $this->translate('No weekdays');
return sprintf(
t('Every %d week(s) on %s at %s'),
$this->translate('Every %d week(s) on %s at %s'),
max(1, $interval),
$weekdayText,
$time !== '' ? $time : '--:--'
);
}
public static function weekdaysFromCsv(?string $csv): array
public function weekdaysFromCsv(?string $csv): array
{
return ScheduleCalculator::parseWeekdays((string) ($csv ?? ''));
return $this->scheduleCalculator->parseWeekdays((string) ($csv ?? ''));
}
private static function validateForm(array $form, array $definition): array
private function validateForm(array $form, array $definition): array
{
$errors = [];
$allowedTypes = $definition['allowed_schedule_types'] ?? ['hourly', 'daily', 'weekly'];
$type = (string) ($form['schedule_type'] ?? 'daily');
if (!in_array($type, $allowedTypes, true)) {
$errors[] = t('Schedule type is invalid');
$errors[] = $this->translate('Schedule type is invalid');
return $errors;
}
$interval = (int) ($form['schedule_interval'] ?? 0);
if ($type === 'hourly' && ($interval < 1 || $interval > 24)) {
$errors[] = t('Hourly interval must be between 1 and 24');
$errors[] = $this->translate('Hourly interval must be between 1 and 24');
} elseif ($type === 'daily' && ($interval < 1 || $interval > 365)) {
$errors[] = t('Daily interval must be between 1 and 365');
$errors[] = $this->translate('Daily interval must be between 1 and 365');
} elseif ($type === 'weekly' && ($interval < 1 || $interval > 52)) {
$errors[] = t('Weekly interval must be between 1 and 52');
$errors[] = $this->translate('Weekly interval must be between 1 and 52');
}
$timezone = trim((string) ($form['timezone'] ?? ''));
try {
new \DateTimeZone($timezone);
} catch (\Throwable $exception) {
$errors[] = t('Timezone is invalid');
$errors[] = $this->translate('Timezone is invalid');
}
if (in_array($type, ['daily', 'weekly'], true) && $form['schedule_time'] === null) {
$errors[] = t('Schedule time is required');
$errors[] = $this->translate('Schedule time is required');
}
if ($type === 'weekly') {
$weekdays = ScheduleCalculator::parseWeekdays((string) ($form['schedule_weekdays_csv'] ?? ''));
$weekdays = $this->scheduleCalculator->parseWeekdays((string) ($form['schedule_weekdays_csv'] ?? ''));
if (!$weekdays) {
$errors[] = t('At least one weekday is required for weekly schedule');
$errors[] = $this->translate('At least one weekday is required for weekly schedule');
}
}
return $errors;
}
private static function buildDefaultJob(array $definition, string $jobKey, \DateTimeImmutable $nowUtc): array
private function buildDefaultJob(array $definition, string $jobKey, \DateTimeImmutable $nowUtc): array
{
$type = (string) ($definition['default_schedule_type'] ?? 'daily');
$job = [
@@ -190,40 +198,40 @@ class ScheduledJobService
'label' => (string) ($definition['label'] ?? $jobKey),
'description' => (string) ($definition['description'] ?? ''),
'enabled' => (int) ($definition['default_enabled'] ?? 1),
'timezone' => ScheduleCalculator::normalizeTimezone((string) ($definition['default_timezone'] ?? '')),
'schedule_type' => ScheduleCalculator::normalizeScheduleType($type),
'schedule_interval' => ScheduleCalculator::normalizeInterval($type, (int) ($definition['default_schedule_interval'] ?? 1)),
'schedule_time' => ScheduleCalculator::normalizeTime((string) ($definition['default_schedule_time'] ?? '')),
'schedule_weekdays_csv' => ScheduleCalculator::normalizeWeekdaysCsv($definition['default_schedule_weekdays_csv'] ?? null),
'timezone' => $this->scheduleCalculator->normalizeTimezone((string) ($definition['default_timezone'] ?? '')),
'schedule_type' => $this->scheduleCalculator->normalizeScheduleType($type),
'schedule_interval' => $this->scheduleCalculator->normalizeInterval($type, (int) ($definition['default_schedule_interval'] ?? 1)),
'schedule_time' => $this->scheduleCalculator->normalizeTime((string) ($definition['default_schedule_time'] ?? '')),
'schedule_weekdays_csv' => $this->scheduleCalculator->normalizeWeekdaysCsv($definition['default_schedule_weekdays_csv'] ?? null),
'catchup_once' => (int) ($definition['default_catchup_once'] ?? 1),
'next_run_at' => null,
];
if ((int) $job['enabled'] === 1) {
$nextRun = ScheduleCalculator::calculateNextRunUtc($job, $nowUtc);
$nextRun = $this->scheduleCalculator->calculateNextRunUtc($job, $nowUtc);
$job['next_run_at'] = $nextRun ? $nextRun->format('Y-m-d H:i:s') : null;
}
return $job;
}
private static function normalizeStoredJob(array $existing, array $definition): array
private function normalizeStoredJob(array $existing, array $definition): array
{
$type = ScheduleCalculator::normalizeScheduleType((string) ($existing['schedule_type'] ?? ($definition['default_schedule_type'] ?? 'daily')));
$type = $this->scheduleCalculator->normalizeScheduleType((string) ($existing['schedule_type'] ?? ($definition['default_schedule_type'] ?? 'daily')));
$normalized = [
'label' => (string) ($definition['label'] ?? (string) ($existing['label'] ?? '')),
'description' => (string) ($definition['description'] ?? (string) ($existing['description'] ?? '')),
'enabled' => (int) ($existing['enabled'] ?? ($definition['default_enabled'] ?? 1)),
'timezone' => ScheduleCalculator::normalizeTimezone((string) ($existing['timezone'] ?? ($definition['default_timezone'] ?? ''))),
'timezone' => $this->scheduleCalculator->normalizeTimezone((string) ($existing['timezone'] ?? ($definition['default_timezone'] ?? ''))),
'schedule_type' => $type,
'schedule_interval' => ScheduleCalculator::normalizeInterval($type, (int) ($existing['schedule_interval'] ?? ($definition['default_schedule_interval'] ?? 1))),
'schedule_time' => ScheduleCalculator::normalizeTime((string) ($existing['schedule_time'] ?? ($definition['default_schedule_time'] ?? ''))),
'schedule_weekdays_csv' => ScheduleCalculator::normalizeWeekdaysCsv(
'schedule_interval' => $this->scheduleCalculator->normalizeInterval($type, (int) ($existing['schedule_interval'] ?? ($definition['default_schedule_interval'] ?? 1))),
'schedule_time' => $this->scheduleCalculator->normalizeTime((string) ($existing['schedule_time'] ?? ($definition['default_schedule_time'] ?? ''))),
'schedule_weekdays_csv' => $this->scheduleCalculator->normalizeWeekdaysCsv(
(string) ($existing['schedule_weekdays_csv'] ?? ($definition['default_schedule_weekdays_csv'] ?? ''))
),
'catchup_once' => (int) ($existing['catchup_once'] ?? ($definition['default_catchup_once'] ?? 1)),
'next_run_at' => (string) ($existing['next_run_at'] ?? ''),
];
if ($normalized['next_run_at'] === '' && (int) $normalized['enabled'] === 1) {
$nextRun = ScheduleCalculator::calculateNextRunUtc($normalized, new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
$nextRun = $this->scheduleCalculator->calculateNextRunUtc($normalized, new \DateTimeImmutable('now', new \DateTimeZone('UTC')));
$normalized['next_run_at'] = $nextRun ? $nextRun->format('Y-m-d H:i:s') : null;
} else {
$normalized['next_run_at'] = $normalized['next_run_at'] !== '' ? $normalized['next_run_at'] : null;
@@ -231,7 +239,7 @@ class ScheduledJobService
return $normalized;
}
private static function jobsDiffer(array $existing, array $normalized): bool
private function jobsDiffer(array $existing, array $normalized): bool
{
$keys = [
'label',
@@ -255,17 +263,28 @@ class ScheduledJobService
return false;
}
private static function weekdayLabel(int $weekday): string
private function weekdayLabel(int $weekday): string
{
return match ($weekday) {
1 => t('Mon'),
2 => t('Tue'),
3 => t('Wed'),
4 => t('Thu'),
5 => t('Fri'),
6 => t('Sat'),
7 => t('Sun'),
1 => $this->translate('Mon'),
2 => $this->translate('Tue'),
3 => $this->translate('Wed'),
4 => $this->translate('Thu'),
5 => $this->translate('Fri'),
6 => $this->translate('Sat'),
7 => $this->translate('Sun'),
default => '-',
};
}
private function translate(string $text, mixed ...$args): string
{
if (function_exists('t')) {
return t($text, ...$args);
}
if ($args === []) {
return $text;
}
return vsprintf($text, array_map(static fn ($arg) => (string) $arg, $args));
}
}

View File

@@ -14,15 +14,25 @@ use MintyPHP\Repository\Support\RepoQuery;
* Acquires a MySQL advisory lock before any work to ensure only one runner
* process is active at a time (safe for minute-by-minute cron invocations).
* Individual jobs are dispatched via ScheduledJobRegistry, and every execution
* attempt including skips and failures is written to the run log.
* attempt - including skips and failures - is written to the run log.
*/
class SchedulerRunService
{
private const RUNNER_LOCK_NAME = 'scheduled_jobs_runner';
public static function runDueJobs(int $limit = 20): array
public function __construct(
private readonly ScheduledJobService $scheduledJobService,
private readonly ScheduledJobRepository $scheduledJobRepository,
private readonly ScheduledJobRunRepository $scheduledJobRunRepository,
private readonly SchedulerRuntimeRepository $schedulerRuntimeRepository,
private readonly ScheduledJobRegistry $scheduledJobRegistry,
private readonly ScheduleCalculator $scheduleCalculator
) {
}
public function runDueJobs(int $limit = 20): array
{
ScheduledJobService::ensureSystemJobs();
$this->scheduledJobService->ensureSystemJobs();
$startedAt = microtime(true);
$result = [
'ok' => true,
@@ -34,19 +44,19 @@ class SchedulerRunService
'skipped' => 0,
];
if (!self::acquireRunnerLock()) {
if (!$this->acquireRunnerLock()) {
$result['ok'] = false;
$result['error'] = 'lock_not_acquired';
$result['duration_ms'] = self::durationMs($startedAt);
self::updateRuntimeHeartbeat('lock_not_acquired', 'lock_not_acquired');
$result['duration_ms'] = $this->durationMs($startedAt);
$this->updateRuntimeHeartbeat('lock_not_acquired', 'lock_not_acquired');
return $result;
}
try {
$nowUtc = gmdate('Y-m-d H:i:s');
$dueJobs = ScheduledJobRepository::listDueJobs($nowUtc, $limit);
$dueJobs = $this->scheduledJobRepository->listDueJobs($nowUtc, $limit);
foreach ($dueJobs as $job) {
$run = self::runOne($job, 'scheduler', null, false);
$run = $this->runOne($job, 'scheduler', null, false);
$result['processed']++;
$status = (string) ($run['status'] ?? 'failed');
if ($status === 'success') {
@@ -61,46 +71,46 @@ class SchedulerRunService
$result['ok'] = false;
$result['error'] = 'unexpected_error';
} finally {
self::releaseRunnerLock();
$result['duration_ms'] = self::durationMs($startedAt);
$this->releaseRunnerLock();
$result['duration_ms'] = $this->durationMs($startedAt);
if ($result['ok']) {
self::updateRuntimeHeartbeat('ok', null);
$this->updateRuntimeHeartbeat('ok', null);
} else {
$errorCode = self::nullableString($result['error'] ?? null) ?? 'unexpected_error';
self::updateRuntimeHeartbeat('unexpected_error', $errorCode);
$errorCode = $this->nullableString($result['error']) ?? 'unexpected_error';
$this->updateRuntimeHeartbeat('unexpected_error', $errorCode);
}
}
return $result;
}
public static function runJobNow(int $jobId, int $actorUserId): array
public function runJobNow(int $jobId, int $actorUserId): array
{
ScheduledJobService::ensureSystemJobs();
$this->scheduledJobService->ensureSystemJobs();
if ($jobId <= 0 || $actorUserId <= 0) {
return ['ok' => false, 'error' => 'invalid_request'];
}
if (!self::acquireRunnerLock()) {
if (!$this->acquireRunnerLock()) {
return ['ok' => false, 'error' => 'lock_not_acquired'];
}
try {
$job = ScheduledJobRepository::find($jobId);
$job = $this->scheduledJobRepository->find($jobId);
if (!$job) {
return ['ok' => false, 'error' => 'job_not_found'];
}
$run = self::runOne($job, 'manual', $actorUserId, true);
$run = $this->runOne($job, 'manual', $actorUserId, true);
return [
'ok' => in_array((string) ($run['status'] ?? ''), ['success', 'skipped'], true),
'status' => (string) ($run['status'] ?? 'failed'),
'error' => (string) ($run['error_code'] ?? ''),
];
} finally {
self::releaseRunnerLock();
$this->releaseRunnerLock();
}
}
private static function runOne(array $job, string $triggerType, ?int $actorUserId, bool $force): array
private function runOne(array $job, string $triggerType, ?int $actorUserId, bool $force): array
{
$jobId = (int) ($job['id'] ?? 0);
$nowUtc = new \DateTimeImmutable('now', new \DateTimeZone('UTC'));
@@ -115,9 +125,9 @@ class SchedulerRunService
return ['status' => 'skipped', 'error_code' => 'job_disabled', 'error_message' => null];
}
$markedRunning = ScheduledJobRepository::markRunning($jobId, $startedAtUtc);
$markedRunning = $this->scheduledJobRepository->markRunning($jobId, $startedAtUtc);
if (!$markedRunning) {
self::insertRunLog([
$this->insertRunLog([
'job_id' => $jobId,
'job_key' => (string) ($job['job_key'] ?? ''),
'trigger_type' => $triggerType,
@@ -140,17 +150,17 @@ class SchedulerRunService
$resultPayload = [];
try {
$definition = ScheduledJobRegistry::get((string) ($job['job_key'] ?? ''));
$definition = $this->scheduledJobRegistry->get((string) ($job['job_key'] ?? ''));
if (!$definition) {
$status = 'failed';
$errorCode = 'job_not_registered';
} else {
$execution = ScheduledJobRegistry::execute((string) $job['job_key'], $actorUserId);
$execution = $this->scheduledJobRegistry->execute((string) $job['job_key'], $actorUserId);
$status = in_array((string) $execution['status'], ['success', 'failed', 'skipped'], true)
? (string) $execution['status']
: 'failed';
$errorCode = self::nullableString($execution['error_code']);
$errorMessage = self::nullableString($execution['error_message']);
$errorCode = $this->nullableString($execution['error_code']);
$errorMessage = $this->nullableString($execution['error_message']);
$resultPayload = $execution['result'];
}
} catch (\Throwable $exception) {
@@ -168,14 +178,14 @@ class SchedulerRunService
if ($triggerType === 'scheduler' && (int) ($job['catchup_once'] ?? 1) === 0 && !empty($job['next_run_at'])) {
// catchup_once = 0: advance next_run_at strictly from the originally scheduled
// time, skipping any windows that already passed. This prevents a backlog storm
// after downtime missed slots are dropped, only the next future slot is kept.
// after downtime - missed slots are dropped, only the next future slot is kept.
// The guard cap (500) prevents an infinite loop if calculateNextRunUtc has a bug
// and keeps returning a time that is still in the past.
$reference = self::parseUtc((string) $job['next_run_at']) ?? $finishedAt;
$next = ScheduleCalculator::calculateNextRunUtc($job, $reference);
$reference = $this->parseUtc((string) $job['next_run_at']) ?? $finishedAt;
$next = $this->scheduleCalculator->calculateNextRunUtc($job, $reference);
$guard = 0;
while ($next && $next <= $finishedAt && $guard < 500) {
$next = ScheduleCalculator::calculateNextRunUtc($job, $next);
$next = $this->scheduleCalculator->calculateNextRunUtc($job, $next);
$guard++;
}
$nextRunUtc = $next ? $next->format('Y-m-d H:i:s') : null;
@@ -183,12 +193,12 @@ class SchedulerRunService
// catchup_once = 1 (default): calculate next_run_at from the actual finish time.
// If a run was delayed or a slot was missed, exactly one catch-up run is scheduled
// starting from now, then normal scheduling resumes.
$next = ScheduleCalculator::calculateNextRunUtc($job, $finishedAt);
$next = $this->scheduleCalculator->calculateNextRunUtc($job, $finishedAt);
$nextRunUtc = $next ? $next->format('Y-m-d H:i:s') : null;
}
}
ScheduledJobRepository::finishRun(
$this->scheduledJobRepository->finishRun(
$jobId,
$status,
$startedAtUtc,
@@ -198,7 +208,7 @@ class SchedulerRunService
$errorMessage
);
self::insertRunLog([
$this->insertRunLog([
'job_id' => $jobId,
'job_key' => (string) ($job['job_key'] ?? ''),
'trigger_type' => $triggerType,
@@ -209,16 +219,16 @@ class SchedulerRunService
'duration_ms' => $durationMs,
'error_code' => $errorCode,
'error_message' => $errorMessage,
'result_json' => self::encodeResult($resultPayload),
'result_json' => $this->encodeResult($resultPayload),
]);
return ['status' => $status, 'error_code' => $errorCode, 'error_message' => $errorMessage];
}
private static function insertRunLog(array $data): void
private function insertRunLog(array $data): void
{
try {
ScheduledJobRunRepository::create([
$this->scheduledJobRunRepository->create([
'run_uuid' => RepoQuery::uuidV4(),
'job_id' => (int) ($data['job_id'] ?? 0),
'job_key' => (string) ($data['job_key'] ?? ''),
@@ -237,7 +247,7 @@ class SchedulerRunService
}
}
private static function encodeResult(array $result): ?string
private function encodeResult(array $result): ?string
{
if (!$result) {
return null;
@@ -246,7 +256,7 @@ class SchedulerRunService
return is_string($encoded) ? $encoded : null;
}
private static function parseUtc(string $value): ?\DateTimeImmutable
private function parseUtc(string $value): ?\DateTimeImmutable
{
$value = trim($value);
if ($value === '') {
@@ -259,28 +269,28 @@ class SchedulerRunService
}
}
private static function nullableString(mixed $value): ?string
private function nullableString(mixed $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
private static function acquireRunnerLock(): bool
private function acquireRunnerLock(): bool
{
$got = DB::selectValue('select GET_LOCK(?, 0) as got_lock', self::RUNNER_LOCK_NAME);
return (int) $got === 1;
}
private static function updateRuntimeHeartbeat(string $status, ?string $errorCode): void
private function updateRuntimeHeartbeat(string $status, ?string $errorCode): void
{
try {
SchedulerRuntimeRepository::touchHeartbeat($status, $errorCode);
$this->schedulerRuntimeRepository->touchHeartbeat($status, $errorCode);
} catch (\Throwable $exception) {
// fail-open
}
}
private static function releaseRunnerLock(): void
private function releaseRunnerLock(): void
{
try {
DB::selectValue('select RELEASE_LOCK(?) as released_lock', self::RUNNER_LOCK_NAME);
@@ -289,7 +299,7 @@ class SchedulerRunService
}
}
private static function durationMs(float $startedAt): int
private function durationMs(float $startedAt): int
{
return (int) max(0, round((microtime(true) - $startedAt) * 1000));
}

View File

@@ -0,0 +1,83 @@
<?php
namespace MintyPHP\Service\Scheduler;
use MintyPHP\Repository\Scheduler\ScheduledJobRepository;
use MintyPHP\Repository\Scheduler\ScheduledJobRunRepository;
use MintyPHP\Repository\Scheduler\SchedulerRuntimeRepository;
use MintyPHP\Service\User\UserLifecycleService;
use MintyPHP\Service\User\UserServicesFactory;
class SchedulerServicesFactory
{
private ?ScheduledJobRepository $scheduledJobRepository = null;
private ?ScheduledJobRunRepository $scheduledJobRunRepository = null;
private ?SchedulerRuntimeRepository $schedulerRuntimeRepository = null;
private ?ScheduleCalculator $scheduleCalculator = null;
private ?UserServicesFactory $userServicesFactory = null;
private ?ScheduledJobRegistry $scheduledJobRegistry = null;
private ?ScheduledJobService $scheduledJobService = null;
private ?SchedulerRunService $schedulerRunService = null;
public function createScheduledJobService(): ScheduledJobService
{
return $this->scheduledJobService ??= new ScheduledJobService(
$this->getScheduledJobRepository(),
$this->getScheduledJobRunRepository(),
$this->getScheduledJobRegistry(),
$this->getScheduleCalculator()
);
}
public function createSchedulerRunService(): SchedulerRunService
{
return $this->schedulerRunService ??= new SchedulerRunService(
$this->createScheduledJobService(),
$this->getScheduledJobRepository(),
$this->getScheduledJobRunRepository(),
$this->getSchedulerRuntimeRepository(),
$this->getScheduledJobRegistry(),
$this->getScheduleCalculator()
);
}
public function createUserLifecycleService(): UserLifecycleService
{
return $this->getUserLifecycleService();
}
private function getScheduledJobRepository(): ScheduledJobRepository
{
return $this->scheduledJobRepository ??= new ScheduledJobRepository();
}
private function getScheduledJobRunRepository(): ScheduledJobRunRepository
{
return $this->scheduledJobRunRepository ??= new ScheduledJobRunRepository();
}
private function getSchedulerRuntimeRepository(): SchedulerRuntimeRepository
{
return $this->schedulerRuntimeRepository ??= new SchedulerRuntimeRepository();
}
private function getScheduleCalculator(): ScheduleCalculator
{
return $this->scheduleCalculator ??= new ScheduleCalculator();
}
private function getUserLifecycleService(): UserLifecycleService
{
return $this->getUserServicesFactory()->createUserLifecycleService();
}
private function getScheduledJobRegistry(): ScheduledJobRegistry
{
return $this->scheduledJobRegistry ??= new ScheduledJobRegistry($this->getUserLifecycleService());
}
private function getUserServicesFactory(): UserServicesFactory
{
return $this->userServicesFactory ??= new UserServicesFactory();
}
}

View File

@@ -10,37 +10,41 @@ class RateLimiterService
{
private const HASH_ALGO = 'sha256';
public static function hit(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array
public function __construct(private readonly RateLimitRepository $rateLimitRepository)
{
return self::apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true);
}
public static function registerFailure(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array
public function hit(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array
{
return self::apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true);
return $this->apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true);
}
public static function isBlocked(string $scope, string $subject): array
public function registerFailure(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array
{
$normalized = self::normalize($scope, $subject);
return $this->apply($scope, $subject, $maxHits, $windowSeconds, $blockSeconds, true);
}
public function isBlocked(string $scope, string $subject): array
{
$normalized = $this->normalize($scope, $subject);
if ($normalized === null) {
return ['allowed' => true, 'retry_after' => 0];
}
try {
$row = RateLimitRepository::findByScopeAndHash($normalized['scope'], $normalized['subject_hash']);
$row = $this->rateLimitRepository->findByScopeAndHash($normalized['scope'], $normalized['subject_hash']);
if (!is_array($row)) {
return ['allowed' => true, 'retry_after' => 0];
}
$blockedUntilTs = self::parseTimestamp((string) ($row['blocked_until'] ?? ''));
$blockedUntilTs = $this->parseTimestamp((string) ($row['blocked_until'] ?? ''));
if ($blockedUntilTs === null) {
return ['allowed' => true, 'retry_after' => 0];
}
$nowTs = time();
if ($blockedUntilTs <= $nowTs) {
RateLimitRepository::updateStateById(
$this->rateLimitRepository->updateStateById(
(int) ($row['id'] ?? 0),
0,
gmdate('Y-m-d H:i:s', $nowTs),
@@ -59,21 +63,21 @@ class RateLimiterService
}
}
public static function reset(string $scope, string $subject): void
public function reset(string $scope, string $subject): void
{
$normalized = self::normalize($scope, $subject);
$normalized = $this->normalize($scope, $subject);
if ($normalized === null) {
return;
}
try {
RateLimitRepository::deleteByScopeAndHash($normalized['scope'], $normalized['subject_hash']);
$this->rateLimitRepository->deleteByScopeAndHash($normalized['scope'], $normalized['subject_hash']);
} catch (\Throwable $exception) {
// Ignore reset failures.
}
}
private static function apply(
private function apply(
string $scope,
string $subject,
int $maxHits,
@@ -81,7 +85,7 @@ class RateLimiterService
int $blockSeconds,
bool $increment
): array {
$normalized = self::normalize($scope, $subject);
$normalized = $this->normalize($scope, $subject);
if ($normalized === null) {
return ['allowed' => true, 'retry_after' => 0];
}
@@ -95,7 +99,7 @@ class RateLimiterService
$nowSql = gmdate('Y-m-d H:i:s', $nowTs);
for ($attempt = 0; $attempt < 2; $attempt++) {
$row = RateLimitRepository::findByScopeAndHash($normalized['scope'], $normalized['subject_hash']);
$row = $this->rateLimitRepository->findByScopeAndHash($normalized['scope'], $normalized['subject_hash']);
if (!is_array($row)) {
if (!$increment) {
@@ -103,26 +107,15 @@ class RateLimiterService
}
$hits = 1;
$blockedUntilTs = null;
if ($hits > $maxHits) {
$blockedUntilTs = $nowTs + $blockSeconds;
}
$created = RateLimitRepository::create(
$created = $this->rateLimitRepository->create(
$normalized['scope'],
$normalized['subject_hash'],
$hits,
$nowSql,
$blockedUntilTs !== null ? gmdate('Y-m-d H:i:s', $blockedUntilTs) : null
null
);
if ($created) {
if ($blockedUntilTs !== null) {
return [
'allowed' => false,
'retry_after' => max(1, $blockedUntilTs - $nowTs),
];
}
return ['allowed' => true, 'retry_after' => 0];
}
@@ -134,7 +127,7 @@ class RateLimiterService
return ['allowed' => true, 'retry_after' => 0];
}
$blockedUntilTs = self::parseTimestamp((string) ($row['blocked_until'] ?? ''));
$blockedUntilTs = $this->parseTimestamp((string) ($row['blocked_until'] ?? ''));
if ($blockedUntilTs !== null && $blockedUntilTs > $nowTs) {
return [
'allowed' => false,
@@ -142,7 +135,7 @@ class RateLimiterService
];
}
$windowStartedTs = self::parseTimestamp((string) ($row['window_started_at'] ?? ''));
$windowStartedTs = $this->parseTimestamp((string) ($row['window_started_at'] ?? ''));
if ($windowStartedTs === null || ($windowStartedTs + $windowSeconds) <= $nowTs) {
$windowStartedTs = $nowTs;
$hits = 0;
@@ -159,7 +152,7 @@ class RateLimiterService
$newBlockedUntilTs = $nowTs + $blockSeconds;
}
RateLimitRepository::updateStateById(
$this->rateLimitRepository->updateStateById(
$id,
$hits,
gmdate('Y-m-d H:i:s', $windowStartedTs),
@@ -183,7 +176,7 @@ class RateLimiterService
}
}
private static function normalize(string $scope, string $subject): ?array
private function normalize(string $scope, string $subject): ?array
{
$scope = strtolower(trim($scope));
$subject = trim($subject);
@@ -202,7 +195,7 @@ class RateLimiterService
];
}
private static function parseTimestamp(string $value): ?int
private function parseTimestamp(string $value): ?int
{
$value = trim($value);
if ($value === '') {

View File

@@ -0,0 +1,21 @@
<?php
namespace MintyPHP\Service\Security;
use MintyPHP\Repository\Security\RateLimitRepository;
class SecurityServicesFactory
{
private ?RateLimitRepository $rateLimitRepository = null;
private ?RateLimiterService $rateLimiterService = null;
public function createRateLimitRepository(): RateLimitRepository
{
return $this->rateLimitRepository ??= new RateLimitRepository();
}
public function createRateLimiterService(): RateLimiterService
{
return $this->rateLimiterService ??= new RateLimiterService($this->createRateLimitRepository());
}
}

View File

@@ -4,11 +4,17 @@ namespace MintyPHP\Service\Settings;
class SettingCacheService
{
private static ?array $settings = null;
private ?array $settings = null;
private ?string $cacheFilePath;
public static function get(string $key): ?string
public function __construct(?string $cacheFilePath = null)
{
$settings = self::all();
$this->cacheFilePath = $cacheFilePath;
}
public function get(string $key): ?string
{
$settings = $this->all();
$value = $settings[$key] ?? null;
if ($value === null) {
return null;
@@ -17,26 +23,26 @@ class SettingCacheService
return $value !== '' ? $value : null;
}
public static function all(): array
public function all(): array
{
if (self::$settings !== null) {
return self::$settings;
if ($this->settings !== null) {
return $this->settings;
}
$file = self::filePath();
$file = $this->filePath();
if (!is_file($file)) {
self::$settings = [];
return self::$settings;
$this->settings = [];
return $this->settings;
}
$loaded = include $file;
self::$settings = is_array($loaded) ? $loaded : [];
return self::$settings;
$this->settings = is_array($loaded) ? $loaded : [];
return $this->settings;
}
public static function update(array $values): bool
public function update(array $values): bool
{
$current = self::all();
$current = $this->all();
foreach ($values as $key => $value) {
$settingKey = trim((string) $key);
if ($settingKey === '') {
@@ -44,12 +50,12 @@ class SettingCacheService
}
$current[$settingKey] = $value === null ? null : (string) $value;
}
return self::write($current);
return $this->write($current);
}
public static function write(array $settings): bool
public function write(array $settings): bool
{
$file = self::filePath();
$file = $this->filePath();
$dir = dirname($file);
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
return false;
@@ -60,7 +66,7 @@ class SettingCacheService
return false;
}
$payload = "<?php\nreturn " . self::exportValue($settings) . ";\n";
$payload = "<?php\nreturn " . $this->exportValue($settings) . ";\n";
$written = @file_put_contents($tmp, $payload, LOCK_EX);
if ($written === false) {
@unlink($tmp);
@@ -72,16 +78,19 @@ class SettingCacheService
return false;
}
self::$settings = $settings;
$this->settings = $settings;
return true;
}
public static function filePath(): string
public function filePath(): string
{
if (is_string($this->cacheFilePath) && $this->cacheFilePath !== '') {
return $this->cacheFilePath;
}
return dirname(__DIR__, 3) . '/config/settings.php';
}
private static function exportValue(mixed $value, int $depth = 0): string
private function exportValue(mixed $value, int $depth = 0): string
{
if (!is_array($value)) {
return var_export($value, true);
@@ -99,7 +108,7 @@ class SettingCacheService
$lines[] = $childIndent
. var_export($key, true)
. ' => '
. self::exportValue($item, $depth + 1)
. $this->exportValue($item, $depth + 1)
. ',';
}

View File

@@ -0,0 +1,265 @@
<?php
namespace MintyPHP\Service\Settings;
class SettingGateway
{
public function __construct(private readonly SettingService $settingService)
{
}
public function get(string $key): ?array
{
return $this->settingService->get($key);
}
public function getValue(string $key): ?string
{
return $this->settingService->getValue($key);
}
public function getDefaultTenantId(): ?int
{
return $this->settingService->getDefaultTenantId();
}
public function setDefaultTenantId(?int $tenantId, ?string $description = null): bool
{
return $this->settingService->setDefaultTenantId($tenantId, $description);
}
public function getDefaultRoleId(): ?int
{
return $this->settingService->getDefaultRoleId();
}
public function setDefaultRoleId(?int $roleId, ?string $description = null): bool
{
return $this->settingService->setDefaultRoleId($roleId, $description);
}
public function getDefaultDepartmentId(): ?int
{
return $this->settingService->getDefaultDepartmentId();
}
public function setDefaultDepartmentId(?int $departmentId, ?string $description = null): bool
{
return $this->settingService->setDefaultDepartmentId($departmentId, $description);
}
public function getAppTitle(): ?string
{
return $this->settingService->getAppTitle();
}
public function setAppTitle(?string $title, ?string $description = null): bool
{
return $this->settingService->setAppTitle($title, $description);
}
public function getAppLocale(): ?string
{
return $this->settingService->getAppLocale();
}
public function setAppLocale(?string $locale, ?string $description = null): bool
{
return $this->settingService->setAppLocale($locale, $description);
}
public function getAppTheme(): ?string
{
return $this->settingService->getAppTheme();
}
public function setAppTheme(?string $theme, ?string $description = null): bool
{
return $this->settingService->setAppTheme($theme, $description);
}
public function isUserThemeAllowed(): bool
{
return $this->settingService->isUserThemeAllowed();
}
public function setUserThemeAllowed(bool $allowed, ?string $description = null): bool
{
return $this->settingService->setUserThemeAllowed($allowed, $description);
}
public function isRegistrationEnabled(): bool
{
return $this->settingService->isRegistrationEnabled();
}
public function setRegistrationEnabled(bool $allowed, ?string $description = null): bool
{
return $this->settingService->setRegistrationEnabled($allowed, $description);
}
public function getApiTokenDefaultTtlDays(): int
{
return $this->settingService->getApiTokenDefaultTtlDays();
}
public function setApiTokenDefaultTtlDays(?int $days, ?string $description = null): bool
{
return $this->settingService->setApiTokenDefaultTtlDays($days, $description);
}
public function getApiTokenMaxTtlDays(): int
{
return $this->settingService->getApiTokenMaxTtlDays();
}
public function setApiTokenMaxTtlDays(?int $days, ?string $description = null): bool
{
return $this->settingService->setApiTokenMaxTtlDays($days, $description);
}
public function getUserInactivityDeactivateDays(): int
{
return $this->settingService->getUserInactivityDeactivateDays();
}
public function setUserInactivityDeactivateDays(?int $days, ?string $description = null): bool
{
return $this->settingService->setUserInactivityDeactivateDays($days, $description);
}
public function getUserInactivityDeleteDays(): int
{
return $this->settingService->getUserInactivityDeleteDays();
}
public function setUserInactivityDeleteDays(?int $days, ?string $description = null): bool
{
return $this->settingService->setUserInactivityDeleteDays($days, $description);
}
public function getApiCorsAllowedOrigins(): array
{
return $this->settingService->getApiCorsAllowedOrigins();
}
public function getApiCorsAllowedOriginsText(): string
{
return $this->settingService->getApiCorsAllowedOriginsText();
}
public function setApiCorsAllowedOrigins(?string $rawOrigins, ?string $description = null): bool
{
return $this->settingService->setApiCorsAllowedOrigins($rawOrigins, $description);
}
public function getMicrosoftSharedClientId(): ?string
{
return $this->settingService->getMicrosoftSharedClientId();
}
public function setMicrosoftSharedClientId(?string $clientId, ?string $description = null): bool
{
return $this->settingService->setMicrosoftSharedClientId($clientId, $description);
}
public function getMicrosoftSharedClientSecret(): ?string
{
return $this->settingService->getMicrosoftSharedClientSecret();
}
public function setMicrosoftSharedClientSecret(?string $secret, ?string $description = null): bool
{
return $this->settingService->setMicrosoftSharedClientSecret($secret, $description);
}
public function getMicrosoftAuthority(): string
{
return $this->settingService->getMicrosoftAuthority();
}
public function setMicrosoftAuthority(?string $authority, ?string $description = null): bool
{
return $this->settingService->setMicrosoftAuthority($authority, $description);
}
public function getAppPrimaryColor(): ?string
{
return $this->settingService->getAppPrimaryColor();
}
public function setAppPrimaryColor(?string $color, ?string $description = null): bool
{
return $this->settingService->setAppPrimaryColor($color, $description);
}
public function getSmtpHost(): ?string
{
return $this->settingService->getSmtpHost();
}
public function setSmtpHost(?string $host, ?string $description = null): bool
{
return $this->settingService->setSmtpHost($host, $description);
}
public function getSmtpPort(): ?int
{
return $this->settingService->getSmtpPort();
}
public function setSmtpPort(?int $port, ?string $description = null): bool
{
return $this->settingService->setSmtpPort($port, $description);
}
public function getSmtpUser(): ?string
{
return $this->settingService->getSmtpUser();
}
public function setSmtpUser(?string $user, ?string $description = null): bool
{
return $this->settingService->setSmtpUser($user, $description);
}
public function getSmtpPassword(): ?string
{
return $this->settingService->getSmtpPassword();
}
public function setSmtpPassword(?string $password, ?string $description = null): bool
{
return $this->settingService->setSmtpPassword($password, $description);
}
public function getSmtpSecure(): ?string
{
return $this->settingService->getSmtpSecure();
}
public function setSmtpSecure(?string $secure, ?string $description = null): bool
{
return $this->settingService->setSmtpSecure($secure, $description);
}
public function getSmtpFrom(): ?string
{
return $this->settingService->getSmtpFrom();
}
public function setSmtpFrom(?string $from, ?string $description = null): bool
{
return $this->settingService->setSmtpFrom($from, $description);
}
public function getSmtpFromName(): ?string
{
return $this->settingService->getSmtpFromName();
}
public function setSmtpFromName(?string $fromName, ?string $description = null): bool
{
return $this->settingService->setSmtpFromName($fromName, $description);
}
}

View File

@@ -2,14 +2,22 @@
namespace MintyPHP\Service\Settings;
use MintyPHP\Repository\Settings\SettingRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Repository\Access\RoleRepository;
use MintyPHP\Repository\Org\DepartmentRepository;
use MintyPHP\Repository\Settings\SettingRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
use MintyPHP\Support\Crypto;
class SettingService
{
public function __construct(
private readonly SettingRepository $settingRepository,
private readonly TenantRepository $tenantRepository,
private readonly RoleRepository $roleRepository,
private readonly DepartmentRepository $departmentRepository,
private readonly ThemeConfigService $themeConfigService
) {
}
public const DEFAULT_TENANT_KEY = 'default_tenant_id';
public const DEFAULT_ROLE_KEY = 'default_role_id';
public const DEFAULT_DEPARTMENT_KEY = 'default_department_id';
@@ -43,112 +51,112 @@ class SettingService
private const USER_INACTIVITY_DAYS_MIN = 0;
private const USER_INACTIVITY_DAYS_MAX = 3650;
public static function get(string $key): ?array
public function get(string $key): ?array
{
return SettingRepository::find($key);
return $this->settingRepository->find($key);
}
public static function getValue(string $key): ?string
public function getValue(string $key): ?string
{
return SettingRepository::getValue($key);
return $this->settingRepository->getValue($key);
}
public static function getInt(string $key): ?int
public function getInt(string $key): ?int
{
$value = SettingRepository::getValue($key);
$value = $this->settingRepository->getValue($key);
if ($value === null || $value === '') {
return null;
}
return (int) $value;
}
public static function set(string $key, ?string $value, ?string $description = null): bool
public function set(string $key, ?string $value, ?string $description = null): bool
{
return SettingRepository::set($key, $value, $description);
return $this->settingRepository->set($key, $value, $description);
}
public static function getDefaultTenantId(): ?int
public function getDefaultTenantId(): ?int
{
$id = self::getInt(self::DEFAULT_TENANT_KEY);
$id = $this->getInt(self::DEFAULT_TENANT_KEY);
return $id && $id > 0 ? $id : null;
}
public static function setDefaultTenantId(?int $tenantId, ?string $description = null): bool
public function setDefaultTenantId(?int $tenantId, ?string $description = null): bool
{
$value = $tenantId && $tenantId > 0 ? (string) $tenantId : null;
if ($value !== null) {
$exists = TenantRepository::find($tenantId);
$exists = $this->tenantRepository->find($tenantId);
if (!$exists) {
return false;
}
}
$desc = $description ?? 'setting.default_tenant';
return SettingRepository::set(self::DEFAULT_TENANT_KEY, $value, $desc);
return $this->settingRepository->set(self::DEFAULT_TENANT_KEY, $value, $desc);
}
public static function getDefaultRoleId(): ?int
public function getDefaultRoleId(): ?int
{
$id = self::getInt(self::DEFAULT_ROLE_KEY);
$id = $this->getInt(self::DEFAULT_ROLE_KEY);
return $id && $id > 0 ? $id : null;
}
public static function setDefaultRoleId(?int $roleId, ?string $description = null): bool
public function setDefaultRoleId(?int $roleId, ?string $description = null): bool
{
$value = $roleId && $roleId > 0 ? (string) $roleId : null;
if ($value !== null) {
$exists = RoleRepository::find($roleId);
$exists = $this->roleRepository->find($roleId);
if (!$exists) {
return false;
}
}
$desc = $description ?? 'setting.default_role';
return SettingRepository::set(self::DEFAULT_ROLE_KEY, $value, $desc);
return $this->settingRepository->set(self::DEFAULT_ROLE_KEY, $value, $desc);
}
public static function getDefaultDepartmentId(): ?int
public function getDefaultDepartmentId(): ?int
{
$id = self::getInt(self::DEFAULT_DEPARTMENT_KEY);
$id = $this->getInt(self::DEFAULT_DEPARTMENT_KEY);
return $id && $id > 0 ? $id : null;
}
public static function setDefaultDepartmentId(?int $departmentId, ?string $description = null): bool
public function setDefaultDepartmentId(?int $departmentId, ?string $description = null): bool
{
$value = $departmentId && $departmentId > 0 ? (string) $departmentId : null;
if ($value !== null) {
$exists = DepartmentRepository::find($departmentId);
$exists = $this->departmentRepository->find($departmentId);
if (!$exists) {
return false;
}
}
$desc = $description ?? 'setting.default_department';
return SettingRepository::set(self::DEFAULT_DEPARTMENT_KEY, $value, $desc);
return $this->settingRepository->set(self::DEFAULT_DEPARTMENT_KEY, $value, $desc);
}
public static function getAppTitle(): ?string
public function getAppTitle(): ?string
{
$value = SettingRepository::getValue(self::APP_TITLE_KEY);
$value = $this->settingRepository->getValue(self::APP_TITLE_KEY);
$value = $value !== null ? trim($value) : null;
return $value !== '' ? $value : null;
}
public static function setAppTitle(?string $title, ?string $description = null): bool
public function setAppTitle(?string $title, ?string $description = null): bool
{
$value = $title !== null ? trim($title) : null;
if ($value === '') {
$value = null;
}
$desc = $description ?? 'setting.app_title';
return SettingRepository::set(self::APP_TITLE_KEY, $value, $desc);
return $this->settingRepository->set(self::APP_TITLE_KEY, $value, $desc);
}
public static function getAppLocale(): ?string
public function getAppLocale(): ?string
{
$value = SettingRepository::getValue(self::APP_LOCALE_KEY);
$value = $this->settingRepository->getValue(self::APP_LOCALE_KEY);
$value = $value !== null ? trim((string) $value) : '';
return $value !== '' ? $value : null;
}
public static function setAppLocale(?string $locale, ?string $description = null): bool
public function setAppLocale(?string $locale, ?string $description = null): bool
{
$value = $locale !== null ? trim((string) $locale) : '';
if ($value === '') {
@@ -160,108 +168,108 @@ class SettingService
}
}
$desc = $description ?? 'setting.app_locale';
return SettingRepository::set(self::APP_LOCALE_KEY, $value, $desc);
return $this->settingRepository->set(self::APP_LOCALE_KEY, $value, $desc);
}
public static function getAppTheme(): ?string
public function getAppTheme(): ?string
{
$value = SettingRepository::getValue(self::APP_THEME_KEY);
$value = $this->settingRepository->getValue(self::APP_THEME_KEY);
$value = $value !== null ? strtolower(trim((string) $value)) : '';
if (!in_array($value, self::allowedThemes(), true)) {
if (!in_array($value, $this->allowedThemes(), true)) {
return null;
}
return $value;
}
public static function setAppTheme(?string $theme, ?string $description = null): bool
public function setAppTheme(?string $theme, ?string $description = null): bool
{
$value = $theme !== null ? strtolower(trim((string) $theme)) : '';
if ($value === '' || !in_array($value, self::allowedThemes(), true)) {
if ($value === '' || !in_array($value, $this->allowedThemes(), true)) {
$value = null;
}
$desc = $description ?? 'setting.app_theme';
return SettingRepository::set(self::APP_THEME_KEY, $value, $desc);
return $this->settingRepository->set(self::APP_THEME_KEY, $value, $desc);
}
public static function isUserThemeAllowed(): bool
public function isUserThemeAllowed(): bool
{
$value = SettingRepository::getValue(self::APP_THEME_USER_KEY);
$value = $this->settingRepository->getValue(self::APP_THEME_USER_KEY);
if ($value === null || $value === '') {
return true;
}
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
public static function setUserThemeAllowed(bool $allowed, ?string $description = null): bool
public function setUserThemeAllowed(bool $allowed, ?string $description = null): bool
{
$value = $allowed ? '1' : '0';
$desc = $description ?? 'setting.app_theme_user';
return SettingRepository::set(self::APP_THEME_USER_KEY, $value, $desc);
return $this->settingRepository->set(self::APP_THEME_USER_KEY, $value, $desc);
}
private static function allowedThemes(): array
private function allowedThemes(): array
{
return array_keys(ThemeConfigService::all());
return array_keys($this->themeConfigService->all());
}
public static function isRegistrationEnabled(): bool
public function isRegistrationEnabled(): bool
{
$value = SettingRepository::getValue(self::APP_REGISTRATION_KEY);
$value = $this->settingRepository->getValue(self::APP_REGISTRATION_KEY);
if ($value === null || $value === '') {
return true;
}
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
public static function setRegistrationEnabled(bool $allowed, ?string $description = null): bool
public function setRegistrationEnabled(bool $allowed, ?string $description = null): bool
{
$value = $allowed ? '1' : '0';
$desc = $description ?? 'setting.app_registration';
return SettingRepository::set(self::APP_REGISTRATION_KEY, $value, $desc);
return $this->settingRepository->set(self::APP_REGISTRATION_KEY, $value, $desc);
}
public static function getApiTokenDefaultTtlDays(): int
public function getApiTokenDefaultTtlDays(): int
{
$maxDays = self::getApiTokenMaxTtlDays();
$value = self::getInt(self::API_TOKEN_DEFAULT_TTL_DAYS_KEY);
$maxDays = $this->getApiTokenMaxTtlDays();
$value = $this->getInt(self::API_TOKEN_DEFAULT_TTL_DAYS_KEY);
if ($value !== null && $value >= self::API_TOKEN_TTL_DAYS_MIN && $value <= self::API_TOKEN_TTL_DAYS_HARD_MAX) {
return min($value, $maxDays);
}
return min(self::API_TOKEN_DEFAULT_TTL_DAYS_FALLBACK, $maxDays);
}
public static function setApiTokenDefaultTtlDays(?int $days, ?string $description = null): bool
public function setApiTokenDefaultTtlDays(?int $days, ?string $description = null): bool
{
$value = $days ?? self::API_TOKEN_DEFAULT_TTL_DAYS_FALLBACK;
if ($value < self::API_TOKEN_TTL_DAYS_MIN || $value > self::API_TOKEN_TTL_DAYS_HARD_MAX) {
return false;
}
if ($value > self::getApiTokenMaxTtlDays()) {
if ($value > $this->getApiTokenMaxTtlDays()) {
return false;
}
$desc = $description ?? 'setting.api_token_default_ttl_days';
return SettingRepository::set(self::API_TOKEN_DEFAULT_TTL_DAYS_KEY, (string) $value, $desc);
return $this->settingRepository->set(self::API_TOKEN_DEFAULT_TTL_DAYS_KEY, (string) $value, $desc);
}
public static function getApiTokenMaxTtlDays(): int
public function getApiTokenMaxTtlDays(): int
{
$value = self::getInt(self::API_TOKEN_MAX_TTL_DAYS_KEY);
$value = $this->getInt(self::API_TOKEN_MAX_TTL_DAYS_KEY);
if ($value !== null && $value >= self::API_TOKEN_TTL_DAYS_MIN && $value <= self::API_TOKEN_TTL_DAYS_HARD_MAX) {
return $value;
}
return self::API_TOKEN_MAX_TTL_DAYS_FALLBACK;
}
public static function setApiTokenMaxTtlDays(?int $days, ?string $description = null): bool
public function setApiTokenMaxTtlDays(?int $days, ?string $description = null): bool
{
$value = $days ?? self::API_TOKEN_MAX_TTL_DAYS_FALLBACK;
if ($value < self::API_TOKEN_TTL_DAYS_MIN || $value > self::API_TOKEN_TTL_DAYS_HARD_MAX) {
return false;
}
$currentDefault = self::getInt(self::API_TOKEN_DEFAULT_TTL_DAYS_KEY);
$currentDefault = $this->getInt(self::API_TOKEN_DEFAULT_TTL_DAYS_KEY);
if ($currentDefault !== null && $currentDefault > $value) {
$defaultUpdated = SettingRepository::set(
$defaultUpdated = $this->settingRepository->set(
self::API_TOKEN_DEFAULT_TTL_DAYS_KEY,
(string) $value,
'setting.api_token_default_ttl_days'
@@ -272,26 +280,26 @@ class SettingService
}
$desc = $description ?? 'setting.api_token_max_ttl_days';
return SettingRepository::set(self::API_TOKEN_MAX_TTL_DAYS_KEY, (string) $value, $desc);
return $this->settingRepository->set(self::API_TOKEN_MAX_TTL_DAYS_KEY, (string) $value, $desc);
}
public static function getUserInactivityDeactivateDays(): int
public function getUserInactivityDeactivateDays(): int
{
$value = self::getInt(self::USER_INACTIVITY_DEACTIVATE_DAYS_KEY);
if ($value !== null && self::isValidInactivityDays($value)) {
$value = $this->getInt(self::USER_INACTIVITY_DEACTIVATE_DAYS_KEY);
if ($value !== null && $this->isValidInactivityDays($value)) {
return $value;
}
return self::USER_INACTIVITY_DEACTIVATE_DAYS_FALLBACK;
}
public static function setUserInactivityDeactivateDays(?int $days, ?string $description = null): bool
public function setUserInactivityDeactivateDays(?int $days, ?string $description = null): bool
{
$value = $days ?? self::USER_INACTIVITY_DEACTIVATE_DAYS_FALLBACK;
if (!self::isValidInactivityDays($value)) {
if (!$this->isValidInactivityDays($value)) {
return false;
}
if ($value === 0) {
$deleteUpdated = SettingRepository::set(
$deleteUpdated = $this->settingRepository->set(
self::USER_INACTIVITY_DELETE_DAYS_KEY,
'0',
'setting.user_inactivity_delete_days'
@@ -301,79 +309,79 @@ class SettingService
}
}
$desc = $description ?? 'setting.user_inactivity_deactivate_days';
return SettingRepository::set(self::USER_INACTIVITY_DEACTIVATE_DAYS_KEY, (string) $value, $desc);
return $this->settingRepository->set(self::USER_INACTIVITY_DEACTIVATE_DAYS_KEY, (string) $value, $desc);
}
public static function getUserInactivityDeleteDays(): int
public function getUserInactivityDeleteDays(): int
{
$deactivateDays = self::getUserInactivityDeactivateDays();
$deactivateDays = $this->getUserInactivityDeactivateDays();
if ($deactivateDays <= 0) {
return 0;
}
$value = self::getInt(self::USER_INACTIVITY_DELETE_DAYS_KEY);
if ($value !== null && self::isValidInactivityDays($value)) {
$value = $this->getInt(self::USER_INACTIVITY_DELETE_DAYS_KEY);
if ($value !== null && $this->isValidInactivityDays($value)) {
return $value;
}
return self::USER_INACTIVITY_DELETE_DAYS_FALLBACK;
}
public static function setUserInactivityDeleteDays(?int $days, ?string $description = null): bool
public function setUserInactivityDeleteDays(?int $days, ?string $description = null): bool
{
$value = $days ?? self::USER_INACTIVITY_DELETE_DAYS_FALLBACK;
if (!self::isValidInactivityDays($value)) {
if (!$this->isValidInactivityDays($value)) {
return false;
}
if ($value > 0 && self::getUserInactivityDeactivateDays() <= 0) {
if ($value > 0 && $this->getUserInactivityDeactivateDays() <= 0) {
return false;
}
$desc = $description ?? 'setting.user_inactivity_delete_days';
return SettingRepository::set(self::USER_INACTIVITY_DELETE_DAYS_KEY, (string) $value, $desc);
return $this->settingRepository->set(self::USER_INACTIVITY_DELETE_DAYS_KEY, (string) $value, $desc);
}
public static function getApiCorsAllowedOrigins(): array
public function getApiCorsAllowedOrigins(): array
{
$stored = SettingRepository::getValue(self::API_CORS_ALLOWED_ORIGINS_KEY);
$stored = $this->settingRepository->getValue(self::API_CORS_ALLOWED_ORIGINS_KEY);
if ($stored === null || trim($stored) === '') {
return [];
}
return self::parseCorsOrigins($stored, false) ?? [];
return $this->parseCorsOrigins($stored, false) ?? [];
}
public static function getApiCorsAllowedOriginsText(): string
public function getApiCorsAllowedOriginsText(): string
{
$origins = self::getApiCorsAllowedOrigins();
$origins = $this->getApiCorsAllowedOrigins();
return implode("\n", $origins);
}
public static function setApiCorsAllowedOrigins(?string $rawOrigins, ?string $description = null): bool
public function setApiCorsAllowedOrigins(?string $rawOrigins, ?string $description = null): bool
{
$value = (string) ($rawOrigins ?? '');
if (trim($value) === '') {
return SettingRepository::set(
return $this->settingRepository->set(
self::API_CORS_ALLOWED_ORIGINS_KEY,
null,
$description ?? 'setting.api_cors_allowed_origins'
);
}
$origins = self::parseCorsOrigins($value, true);
$origins = $this->parseCorsOrigins($value, true);
if ($origins === null) {
return false;
}
return SettingRepository::set(
return $this->settingRepository->set(
self::API_CORS_ALLOWED_ORIGINS_KEY,
implode("\n", $origins),
$description ?? 'setting.api_cors_allowed_origins'
);
}
private static function parseCorsOrigins(string $rawOrigins, bool $strict): ?array
private function parseCorsOrigins(string $rawOrigins, bool $strict): ?array
{
$parts = preg_split('/[\r\n,]+/', $rawOrigins) ?: [];
$normalizedOrigins = [];
foreach ($parts as $part) {
$origin = self::normalizeCorsOrigin($part);
$origin = $this->normalizeCorsOrigin($part);
if ($origin === null) {
if ($strict && trim((string) $part) !== '') {
return null;
@@ -385,7 +393,7 @@ class SettingService
return array_keys($normalizedOrigins);
}
private static function normalizeCorsOrigin(string $origin): ?string
private function normalizeCorsOrigin(string $origin): ?string
{
$origin = trim($origin);
if ($origin === '') {
@@ -426,33 +434,33 @@ class SettingService
return $scheme . '://' . $host . $portSuffix;
}
public static function getMicrosoftSharedClientId(): ?string
public function getMicrosoftSharedClientId(): ?string
{
$value = SettingRepository::getValue(self::MICROSOFT_SHARED_CLIENT_ID_KEY);
$value = $this->settingRepository->getValue(self::MICROSOFT_SHARED_CLIENT_ID_KEY);
$value = $value !== null ? trim((string) $value) : '';
return $value !== '' ? $value : null;
}
public static function setMicrosoftSharedClientId(?string $clientId, ?string $description = null): bool
public function setMicrosoftSharedClientId(?string $clientId, ?string $description = null): bool
{
$value = $clientId !== null ? trim((string) $clientId) : '';
if ($value === '') {
$value = null;
}
$desc = $description ?? 'setting.microsoft_shared_client_id';
return SettingRepository::set(self::MICROSOFT_SHARED_CLIENT_ID_KEY, $value, $desc);
return $this->settingRepository->set(self::MICROSOFT_SHARED_CLIENT_ID_KEY, $value, $desc);
}
public static function getMicrosoftSharedClientSecretEncrypted(): ?string
public function getMicrosoftSharedClientSecretEncrypted(): ?string
{
$value = SettingRepository::getValue(self::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY);
$value = $this->settingRepository->getValue(self::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY);
$value = $value !== null ? trim((string) $value) : '';
return $value !== '' ? $value : null;
}
public static function getMicrosoftSharedClientSecret(): ?string
public function getMicrosoftSharedClientSecret(): ?string
{
$encrypted = self::getMicrosoftSharedClientSecretEncrypted();
$encrypted = $this->getMicrosoftSharedClientSecretEncrypted();
if ($encrypted === null) {
return null;
}
@@ -465,11 +473,11 @@ class SettingService
return $decrypted !== '' ? $decrypted : null;
}
public static function setMicrosoftSharedClientSecret(?string $secret, ?string $description = null): bool
public function setMicrosoftSharedClientSecret(?string $secret, ?string $description = null): bool
{
$value = $secret !== null ? trim((string) $secret) : '';
if ($value === '') {
return SettingRepository::set(
return $this->settingRepository->set(
self::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY,
null,
$description ?? 'setting.microsoft_shared_client_secret_enc'
@@ -477,12 +485,12 @@ class SettingService
}
$encrypted = Crypto::encryptString($value);
$desc = $description ?? 'setting.microsoft_shared_client_secret_enc';
return SettingRepository::set(self::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY, $encrypted, $desc);
return $this->settingRepository->set(self::MICROSOFT_SHARED_CLIENT_SECRET_ENC_KEY, $encrypted, $desc);
}
public static function getMicrosoftAuthority(): string
public function getMicrosoftAuthority(): string
{
$value = SettingRepository::getValue(self::MICROSOFT_AUTHORITY_KEY);
$value = $this->settingRepository->getValue(self::MICROSOFT_AUTHORITY_KEY);
$value = trim((string) ($value ?? ''));
if ($value === '') {
return 'https://login.microsoftonline.com/common/v2.0';
@@ -490,7 +498,7 @@ class SettingService
return rtrim($value, '/');
}
public static function setMicrosoftAuthority(?string $authority, ?string $description = null): bool
public function setMicrosoftAuthority(?string $authority, ?string $description = null): bool
{
$value = trim((string) ($authority ?? ''));
if ($value === '') {
@@ -500,12 +508,12 @@ class SettingService
return false;
}
$desc = $description ?? 'setting.microsoft_authority';
return SettingRepository::set(self::MICROSOFT_AUTHORITY_KEY, rtrim($value, '/'), $desc);
return $this->settingRepository->set(self::MICROSOFT_AUTHORITY_KEY, rtrim($value, '/'), $desc);
}
public static function getAppPrimaryColor(): ?string
public function getAppPrimaryColor(): ?string
{
$value = SettingRepository::getValue(self::APP_PRIMARY_COLOR_KEY);
$value = $this->settingRepository->getValue(self::APP_PRIMARY_COLOR_KEY);
$value = $value !== null ? strtolower(trim((string) $value)) : '';
if ($value === '') {
return null;
@@ -516,7 +524,7 @@ class SettingService
return $value;
}
public static function setAppPrimaryColor(?string $color, ?string $description = null): bool
public function setAppPrimaryColor(?string $color, ?string $description = null): bool
{
$value = $color !== null ? strtolower(trim((string) $color)) : '';
if ($value !== '' && $value[0] !== '#') {
@@ -533,29 +541,29 @@ class SettingService
return false;
}
$desc = $description ?? 'setting.app_primary_color';
return SettingRepository::set(self::APP_PRIMARY_COLOR_KEY, $value, $desc);
return $this->settingRepository->set(self::APP_PRIMARY_COLOR_KEY, $value, $desc);
}
public static function getSmtpHost(): ?string
public function getSmtpHost(): ?string
{
$value = SettingRepository::getValue(self::SMTP_HOST_KEY);
$value = $this->settingRepository->getValue(self::SMTP_HOST_KEY);
$value = $value !== null ? trim((string) $value) : '';
return $value !== '' ? $value : null;
}
public static function setSmtpHost(?string $host, ?string $description = null): bool
public function setSmtpHost(?string $host, ?string $description = null): bool
{
$value = $host !== null ? trim((string) $host) : '';
if ($value === '') {
$value = null;
}
$desc = $description ?? 'setting.smtp_host';
return SettingRepository::set(self::SMTP_HOST_KEY, $value, $desc);
return $this->settingRepository->set(self::SMTP_HOST_KEY, $value, $desc);
}
public static function getSmtpPort(): ?int
public function getSmtpPort(): ?int
{
$value = SettingRepository::getValue(self::SMTP_PORT_KEY);
$value = $this->settingRepository->getValue(self::SMTP_PORT_KEY);
if ($value === null || $value === '') {
return null;
}
@@ -563,50 +571,50 @@ class SettingService
return $port > 0 ? $port : null;
}
public static function setSmtpPort(?int $port, ?string $description = null): bool
public function setSmtpPort(?int $port, ?string $description = null): bool
{
$value = $port && $port > 0 ? (string) $port : null;
$desc = $description ?? 'setting.smtp_port';
return SettingRepository::set(self::SMTP_PORT_KEY, $value, $desc);
return $this->settingRepository->set(self::SMTP_PORT_KEY, $value, $desc);
}
public static function getSmtpUser(): ?string
public function getSmtpUser(): ?string
{
$value = SettingRepository::getValue(self::SMTP_USER_KEY);
$value = $this->settingRepository->getValue(self::SMTP_USER_KEY);
$value = $value !== null ? trim((string) $value) : '';
return $value !== '' ? $value : null;
}
public static function setSmtpUser(?string $user, ?string $description = null): bool
public function setSmtpUser(?string $user, ?string $description = null): bool
{
$value = $user !== null ? trim((string) $user) : '';
if ($value === '') {
$value = null;
}
$desc = $description ?? 'setting.smtp_user';
return SettingRepository::set(self::SMTP_USER_KEY, $value, $desc);
return $this->settingRepository->set(self::SMTP_USER_KEY, $value, $desc);
}
public static function getSmtpPassword(): ?string
public function getSmtpPassword(): ?string
{
$value = SettingRepository::getValue(self::SMTP_PASSWORD_KEY);
$value = $this->settingRepository->getValue(self::SMTP_PASSWORD_KEY);
$value = $value !== null ? (string) $value : '';
return $value !== '' ? $value : null;
}
public static function setSmtpPassword(?string $password, ?string $description = null): bool
public function setSmtpPassword(?string $password, ?string $description = null): bool
{
$value = $password !== null ? (string) $password : '';
if ($value === '') {
$value = null;
}
$desc = $description ?? 'setting.smtp_password';
return SettingRepository::set(self::SMTP_PASSWORD_KEY, $value, $desc);
return $this->settingRepository->set(self::SMTP_PASSWORD_KEY, $value, $desc);
}
public static function getSmtpSecure(): ?string
public function getSmtpSecure(): ?string
{
$value = SettingRepository::getValue(self::SMTP_SECURE_KEY);
$value = $this->settingRepository->getValue(self::SMTP_SECURE_KEY);
$value = $value !== null ? strtolower(trim((string) $value)) : '';
if (!in_array($value, ['tls', 'ssl', 'none'], true)) {
return null;
@@ -614,7 +622,7 @@ class SettingService
return $value === 'none' ? null : $value;
}
public static function setSmtpSecure(?string $secure, ?string $description = null): bool
public function setSmtpSecure(?string $secure, ?string $description = null): bool
{
$value = $secure !== null ? strtolower(trim((string) $secure)) : '';
if ($value === '' || $value === 'none') {
@@ -623,44 +631,44 @@ class SettingService
return false;
}
$desc = $description ?? 'setting.smtp_secure';
return SettingRepository::set(self::SMTP_SECURE_KEY, $value, $desc);
return $this->settingRepository->set(self::SMTP_SECURE_KEY, $value, $desc);
}
public static function getSmtpFrom(): ?string
public function getSmtpFrom(): ?string
{
$value = SettingRepository::getValue(self::SMTP_FROM_KEY);
$value = $this->settingRepository->getValue(self::SMTP_FROM_KEY);
$value = $value !== null ? trim((string) $value) : '';
return $value !== '' ? $value : null;
}
public static function setSmtpFrom(?string $from, ?string $description = null): bool
public function setSmtpFrom(?string $from, ?string $description = null): bool
{
$value = $from !== null ? trim((string) $from) : '';
if ($value === '') {
$value = null;
}
$desc = $description ?? 'setting.smtp_from';
return SettingRepository::set(self::SMTP_FROM_KEY, $value, $desc);
return $this->settingRepository->set(self::SMTP_FROM_KEY, $value, $desc);
}
public static function getSmtpFromName(): ?string
public function getSmtpFromName(): ?string
{
$value = SettingRepository::getValue(self::SMTP_FROM_NAME_KEY);
$value = $this->settingRepository->getValue(self::SMTP_FROM_NAME_KEY);
$value = $value !== null ? trim((string) $value) : '';
return $value !== '' ? $value : null;
}
public static function setSmtpFromName(?string $fromName, ?string $description = null): bool
public function setSmtpFromName(?string $fromName, ?string $description = null): bool
{
$value = $fromName !== null ? trim((string) $fromName) : '';
if ($value === '') {
$value = null;
}
$desc = $description ?? 'setting.smtp_from_name';
return SettingRepository::set(self::SMTP_FROM_NAME_KEY, $value, $desc);
return $this->settingRepository->set(self::SMTP_FROM_NAME_KEY, $value, $desc);
}
private static function isValidInactivityDays(int $days): bool
private function isValidInactivityDays(int $days): bool
{
return $days >= self::USER_INACTIVITY_DAYS_MIN && $days <= self::USER_INACTIVITY_DAYS_MAX;
}

View File

@@ -0,0 +1,66 @@
<?php
namespace MintyPHP\Service\Settings;
use MintyPHP\Repository\Access\RoleRepository;
use MintyPHP\Repository\Org\DepartmentRepository;
use MintyPHP\Repository\Settings\SettingRepository;
use MintyPHP\Repository\Tenant\TenantRepository;
class SettingServicesFactory
{
private ?SettingRepository $settingRepository = null;
private ?TenantRepository $tenantRepository = null;
private ?RoleRepository $roleRepository = null;
private ?DepartmentRepository $departmentRepository = null;
private ?ThemeConfigService $themeConfigService = null;
private ?SettingService $settingService = null;
private ?SettingCacheService $settingCacheService = null;
private ?SettingGateway $settingGateway = null;
public function createSettingRepository(): SettingRepository
{
return $this->settingRepository ??= new SettingRepository();
}
public function createSettingService(): SettingService
{
return $this->settingService ??= new SettingService(
$this->createSettingRepository(),
$this->createTenantRepository(),
$this->createRoleRepository(),
$this->createDepartmentRepository(),
$this->createThemeConfigService()
);
}
public function createSettingGateway(): SettingGateway
{
return $this->settingGateway ??= new SettingGateway($this->createSettingService());
}
public function createSettingCacheService(): SettingCacheService
{
return $this->settingCacheService ??= new SettingCacheService();
}
public function createThemeConfigService(): ThemeConfigService
{
return $this->themeConfigService ??= new ThemeConfigService();
}
private function createTenantRepository(): TenantRepository
{
return $this->tenantRepository ??= new TenantRepository();
}
private function createRoleRepository(): RoleRepository
{
return $this->roleRepository ??= new RoleRepository();
}
private function createDepartmentRepository(): DepartmentRepository
{
return $this->departmentRepository ??= new DepartmentRepository();
}
}

Some files were not shown because too many files have changed in this diff Show More