add composer-unused, comprehensive docs, and project restructure

- Add icanhazstring/composer-unused as dev dependency for dependency hygiene checks
- Add German documentation (docs/) covering architecture, conventions, workflows, and developer checklists
- Add API layer (ApiAuth, ApiBootstrap, ApiResponse), audit, scheduler, custom fields, and SSO services
- Add Microsoft OIDC SSO, API token management, and user lifecycle features
- Add swagger-ui vendor integration and OpenAPI spec
- Add production Docker setup and bin/ scripts
- Update composer dependencies, config, templates, and frontend assets throughout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 15:27:35 +01:00
parent 3eb9cc0ac4
commit 25370a1a55
389 changed files with 40506 additions and 8071 deletions

View File

@@ -22,6 +22,38 @@ class RolePermissionRepository
return array_values(array_unique($ids));
}
public static 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));
if (!$roleIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($roleIds), '?'));
$rows = DB::select(
'select rp.role_id, count(distinct rp.permission_id) as permission_count from role_permissions rp where rp.role_id in (' .
$placeholders .
') group by rp.role_id',
...array_map('strval', $roleIds)
);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$roleId = self::extractIntField($row, 'role_id');
if ($roleId <= 0) {
continue;
}
$result[$roleId] = self::extractIntField($row, 'permission_count');
}
return $result;
}
public static function replaceForRole(int $roleId, array $permissionIds): bool
{
DB::delete('delete from role_permissions where role_id = ?', (string) $roleId);
@@ -123,27 +155,43 @@ class RolePermissionRepository
$rowRp = $row['rp'] ?? ($row['role_permissions'] ?? $row);
$permId = $rowRp['permission_id'] ?? ($row['permission_id'] ?? null);
$key = (string) (($rowPerm['key'] ?? $row['key'] ?? '') ?? '');
$key = (string) ($rowPerm['key'] ?? $row['key'] ?? '');
if ($permId === null || $key === '') {
continue;
}
if (!isset($permMap[$permId])) {
$desc = (string) (($rowPerm['description'] ?? $row['description'] ?? '') ?? '');
$desc = (string) ($rowPerm['description'] ?? $row['description'] ?? '');
$permMap[$permId] = [
'key' => $key,
'description' => $desc,
'roles' => [],
];
}
$roleLabel = (string) (($rowRole['description'] ?? $rowRole['role'] ?? $row['role'] ?? '') ?? '');
$roleLabel = (string) ($rowRole['description'] ?? $rowRole['role'] ?? $row['role'] ?? '');
if ($roleLabel !== '') {
$permMap[$permId]['roles'][] = $roleLabel;
}
}
foreach ($permMap as &$item) {
$item['roles'] = array_values(array_unique($item['roles'] ?? []));
$item['roles'] = array_values(array_unique($item['roles']));
}
unset($item);
return array_values($permMap);
}
private static function extractIntField(array $row, string $field): int
{
foreach ($row as $value) {
if (!is_array($value)) {
continue;
}
if (array_key_exists($field, $value)) {
return (int) $value[$field];
}
}
if (array_key_exists($field, $row)) {
return (int) $row[$field];
}
return 0;
}
}

View File

@@ -46,6 +46,22 @@ class RoleRepository
return self::unwrapList($rows);
}
public static 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));
if (!$roleIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($roleIds), '?'));
$rows = DB::select(
'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);
}
public static function listIds(): array
{
$rows = DB::select('select id from roles');
@@ -143,19 +159,11 @@ class RoleRepository
return $count ? ((int) $count > 0) : false;
}
private static function uuidV4(): string
{
$data = random_bytes(16);
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
public static function create(array $data)
{
return DB::insert(
'insert into roles (uuid, description, code, active, created_by, created) values (?,?,?,?,?,NOW())',
$data['uuid'] ?? self::uuidV4(),
$data['uuid'] ?? RepoQuery::uuidV4(),
$data['description'],
$data['code'] ?? null,
$data['active'] ?? 1,

View File

@@ -39,4 +39,63 @@ class UserRoleRepository
return true;
}
public static function countUsersByRoleIds(array $roleIds): array
{
return self::countByRoleIds($roleIds, false);
}
public static function countActiveUsersByRoleIds(array $roleIds): array
{
return self::countByRoleIds($roleIds, true);
}
private static 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));
if (!$roleIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($roleIds), '?'));
$joinUsersSql = $activeOnly ? ' join users u on u.id = ur.user_id and u.active = 1' : '';
$rows = DB::select(
'select ur.role_id, count(distinct ur.user_id) as user_count from user_roles ur' .
$joinUsersSql .
' where ur.role_id in (' . $placeholders . ') group by ur.role_id',
...array_map('strval', $roleIds)
);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$roleId = self::extractIntField($row, 'role_id');
if ($roleId <= 0) {
continue;
}
$result[$roleId] = self::extractIntField($row, 'user_count');
}
return $result;
}
private static function extractIntField(array $row, string $field): int
{
foreach ($row as $value) {
if (!is_array($value)) {
continue;
}
if (array_key_exists($field, $value)) {
return (int) $value[$field];
}
}
if (array_key_exists($field, $row)) {
return (int) $row[$field];
}
return 0;
}
}

View File

@@ -0,0 +1,223 @@
<?php
namespace MintyPHP\Repository\Audit;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class ApiAuditLogRepository
{
public static function create(array $data): int|false
{
$id = DB::insert(
'insert into api_audit_log (
request_id, method, path, query_json, status_code, duration_ms, error_code,
user_id, tenant_id, api_token_id, token_tenant_id, ip, user_agent, created_at
) values (?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
(string) ($data['request_id'] ?? ''),
(string) ($data['method'] ?? ''),
(string) ($data['path'] ?? ''),
$data['query_json'] ?? null,
(string) ((int) ($data['status_code'] ?? 0)),
$data['duration_ms'] !== null ? (string) ((int) $data['duration_ms']) : null,
$data['error_code'] ?? null,
$data['user_id'] !== null ? (string) ((int) $data['user_id']) : null,
$data['tenant_id'] !== null ? (string) ((int) $data['tenant_id']) : null,
$data['api_token_id'] !== null ? (string) ((int) $data['api_token_id']) : null,
$data['token_tenant_id'] !== null ? (string) ((int) $data['token_tenant_id']) : null,
$data['ip'] ?? null,
$data['user_agent'] ?? null
);
return $id ? (int) $id : false;
}
public static function listPaged(array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$status = strtolower(trim((string) ($filters['status'] ?? '')));
$method = strtoupper(trim((string) ($filters['method'] ?? '')));
$createdFrom = trim((string) ($filters['created_from'] ?? ''));
$createdTo = trim((string) ($filters['created_to'] ?? ''));
$tenantId = (int) ($filters['tenant_id'] ?? 0);
$userId = (int) ($filters['user_id'] ?? 0);
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'created_at', 'status_code', 'duration_ms', 'method', 'path'],
'created_at',
'desc'
);
$where = [];
$params = [];
RepoQuery::addLikeFilter(
$where,
$params,
['api_audit_log.path', 'api_audit_log.request_id', 'api_audit_log.ip', 'api_audit_log.error_code'],
$search
);
if (in_array($status, ['2xx', '4xx', '5xx'], true)) {
$rangeStart = (int) $status[0] * 100;
$where[] = 'api_audit_log.status_code between ? and ?';
$params[] = (string) $rangeStart;
$params[] = (string) ($rangeStart + 99);
} elseif ($status !== '' && ctype_digit($status)) {
$statusCode = (int) $status;
if ($statusCode >= 100 && $statusCode <= 599) {
$where[] = 'api_audit_log.status_code = ?';
$params[] = (string) $statusCode;
}
}
if (in_array($method, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'], true)) {
$where[] = 'api_audit_log.method = ?';
$params[] = $method;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) {
$where[] = 'api_audit_log.created_at >= ?';
$params[] = $createdFrom . ' 00:00:00';
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
$where[] = 'api_audit_log.created_at <= ?';
$params[] = $createdTo . ' 23:59:59';
}
if ($tenantId > 0) {
$where[] = 'api_audit_log.tenant_id = ?';
$params[] = (string) $tenantId;
}
if ($userId > 0) {
$where[] = 'api_audit_log.user_id = ?';
$params[] = (string) $userId;
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$fromSql = ' from api_audit_log ' .
'left join users on users.id = api_audit_log.user_id ' .
'left join tenants on tenants.id = api_audit_log.tenant_id ';
$total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select
api_audit_log.id,
api_audit_log.request_id,
api_audit_log.method,
api_audit_log.path,
api_audit_log.query_json,
api_audit_log.status_code,
api_audit_log.duration_ms,
api_audit_log.error_code,
api_audit_log.user_id,
api_audit_log.tenant_id,
api_audit_log.api_token_id,
api_audit_log.token_tenant_id,
api_audit_log.ip,
api_audit_log.user_agent,
api_audit_log.created_at,
users.id,
users.uuid,
users.display_name,
users.email,
tenants.id,
tenants.uuid,
tenants.description
' . $fromSql . $whereSql .
sprintf(' order by api_audit_log.`%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = self::normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return [
'total' => $total,
'rows' => $normalized,
];
}
public static function find(int $id): ?array
{
$row = DB::selectOne(
'select
api_audit_log.id,
api_audit_log.request_id,
api_audit_log.method,
api_audit_log.path,
api_audit_log.query_json,
api_audit_log.status_code,
api_audit_log.duration_ms,
api_audit_log.error_code,
api_audit_log.user_id,
api_audit_log.tenant_id,
api_audit_log.api_token_id,
api_audit_log.token_tenant_id,
api_audit_log.ip,
api_audit_log.user_agent,
api_audit_log.created_at,
users.id,
users.uuid,
users.display_name,
users.email,
tenants.id,
tenants.uuid,
tenants.description
from api_audit_log
left join users on users.id = api_audit_log.user_id
left join tenants on tenants.id = api_audit_log.tenant_id
where api_audit_log.id = ?
limit 1',
(string) $id
);
return self::normalizeRow($row);
}
public static function purgeOlderThanDays(int $days): int
{
if ($days <= 0) {
return 0;
}
$cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('-' . $days . ' days')
->format('Y-m-d H:i:s');
$deleted = DB::delete('delete from api_audit_log where created_at < ?', $cutoff);
return is_int($deleted) ? $deleted : 0;
}
private static function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$log = $row['api_audit_log'] ?? [];
if (!is_array($log) || !isset($log['id'])) {
return null;
}
$user = is_array($row['users'] ?? null) ? $row['users'] : [];
$tenant = is_array($row['tenants'] ?? null) ? $row['tenants'] : [];
$log['user_uuid'] = (string) ($user['uuid'] ?? '');
$log['user_display_name'] = (string) ($user['display_name'] ?? '');
$log['user_email'] = (string) ($user['email'] ?? '');
$log['tenant_uuid'] = (string) ($tenant['uuid'] ?? '');
$log['tenant_description'] = (string) ($tenant['description'] ?? '');
return $log;
}
}

View File

@@ -0,0 +1,251 @@
<?php
namespace MintyPHP\Repository\Audit;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class ImportAuditRunRepository
{
public static function createRunning(array $data): int|false
{
$id = DB::insert(
'insert into import_audit_runs (
run_uuid, profile_key, status, source_filename, mapped_targets_csv, started_at, user_id, current_tenant_id
) values (?,?,?,?,?,NOW(),?,?)',
(string) ($data['run_uuid'] ?? ''),
(string) ($data['profile_key'] ?? ''),
(string) ($data['status'] ?? 'running'),
$data['source_filename'] ?? null,
$data['mapped_targets_csv'] ?? null,
$data['user_id'] !== null ? (string) ((int) $data['user_id']) : null,
$data['current_tenant_id'] !== null ? (string) ((int) $data['current_tenant_id']) : null
);
return $id ? (int) $id : false;
}
public static function finishById(int $id, array $data): bool
{
if ($id <= 0) {
return false;
}
$updated = DB::update(
'update import_audit_runs
set status = ?,
rows_total = ?,
created_count = ?,
skipped_count = ?,
failed_count = ?,
error_codes_json = ?,
duration_ms = ?,
finished_at = NOW()
where id = ?',
(string) ($data['status'] ?? 'failed'),
(string) ((int) ($data['rows_total'] ?? 0)),
(string) ((int) ($data['created_count'] ?? 0)),
(string) ((int) ($data['skipped_count'] ?? 0)),
(string) ((int) ($data['failed_count'] ?? 0)),
$data['error_codes_json'] ?? null,
$data['duration_ms'] !== null ? (string) ((int) $data['duration_ms']) : null,
(string) $id
);
return (int) $updated > 0;
}
public static function listPaged(array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$profileKey = strtolower(trim((string) ($filters['profile_key'] ?? '')));
$status = strtolower(trim((string) ($filters['status'] ?? '')));
$createdFrom = trim((string) ($filters['created_from'] ?? ''));
$createdTo = trim((string) ($filters['created_to'] ?? ''));
$userId = (int) ($filters['user_id'] ?? 0);
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
[
'id',
'started_at',
'finished_at',
'duration_ms',
'rows_total',
'created_count',
'skipped_count',
'failed_count',
'status',
'profile_key',
],
'started_at',
'desc'
);
$where = [];
$params = [];
RepoQuery::addLikeFilter(
$where,
$params,
[
'import_audit_runs.run_uuid',
'import_audit_runs.source_filename',
'import_audit_runs.error_codes_json',
],
$search
);
if (in_array($profileKey, ['users', 'departments'], true)) {
$where[] = 'import_audit_runs.profile_key = ?';
$params[] = $profileKey;
}
if (in_array($status, ['running', 'success', 'partial', 'failed'], true)) {
$where[] = 'import_audit_runs.status = ?';
$params[] = $status;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) {
$where[] = 'import_audit_runs.started_at >= ?';
$params[] = $createdFrom . ' 00:00:00';
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
$where[] = 'import_audit_runs.started_at <= ?';
$params[] = $createdTo . ' 23:59:59';
}
if ($userId > 0) {
$where[] = 'import_audit_runs.user_id = ?';
$params[] = (string) $userId;
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$fromSql = ' from import_audit_runs ' .
'left join users on users.id = import_audit_runs.user_id ' .
'left join tenants on tenants.id = import_audit_runs.current_tenant_id ';
$total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select
import_audit_runs.id,
import_audit_runs.run_uuid,
import_audit_runs.profile_key,
import_audit_runs.status,
import_audit_runs.source_filename,
import_audit_runs.mapped_targets_csv,
import_audit_runs.rows_total,
import_audit_runs.created_count,
import_audit_runs.skipped_count,
import_audit_runs.failed_count,
import_audit_runs.error_codes_json,
import_audit_runs.started_at,
import_audit_runs.finished_at,
import_audit_runs.duration_ms,
import_audit_runs.user_id,
import_audit_runs.current_tenant_id,
users.id,
users.uuid,
users.display_name,
users.email,
tenants.id,
tenants.uuid,
tenants.description
' . $fromSql . $whereSql .
sprintf(' order by import_audit_runs.`%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = self::normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return [
'total' => $total,
'rows' => $normalized,
];
}
public static function find(int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = DB::selectOne(
'select
import_audit_runs.id,
import_audit_runs.run_uuid,
import_audit_runs.profile_key,
import_audit_runs.status,
import_audit_runs.source_filename,
import_audit_runs.mapped_targets_csv,
import_audit_runs.rows_total,
import_audit_runs.created_count,
import_audit_runs.skipped_count,
import_audit_runs.failed_count,
import_audit_runs.error_codes_json,
import_audit_runs.started_at,
import_audit_runs.finished_at,
import_audit_runs.duration_ms,
import_audit_runs.user_id,
import_audit_runs.current_tenant_id,
users.id,
users.uuid,
users.display_name,
users.email,
tenants.id,
tenants.uuid,
tenants.description
from import_audit_runs
left join users on users.id = import_audit_runs.user_id
left join tenants on tenants.id = import_audit_runs.current_tenant_id
where import_audit_runs.id = ?
limit 1',
(string) $id
);
return self::normalizeRow($row);
}
public static function purgeOlderThanDays(int $days): int
{
if ($days <= 0) {
return 0;
}
$cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('-' . $days . ' days')
->format('Y-m-d H:i:s');
$deleted = DB::delete('delete from import_audit_runs where started_at < ?', $cutoff);
return is_int($deleted) ? $deleted : 0;
}
private static function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$item = $row['import_audit_runs'] ?? [];
if (!is_array($item) || !isset($item['id'])) {
return null;
}
$user = is_array($row['users'] ?? null) ? $row['users'] : [];
$tenant = is_array($row['tenants'] ?? null) ? $row['tenants'] : [];
$item['user_uuid'] = (string) ($user['uuid'] ?? '');
$item['user_display_name'] = (string) ($user['display_name'] ?? '');
$item['user_email'] = (string) ($user['email'] ?? '');
$item['current_tenant_uuid'] = (string) ($tenant['uuid'] ?? '');
$item['current_tenant_description'] = (string) ($tenant['description'] ?? '');
return $item;
}
}

View File

@@ -0,0 +1,297 @@
<?php
namespace MintyPHP\Repository\Audit;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class UserLifecycleAuditRepository
{
public static function create(array $row): int|false
{
$id = DB::insert(
'insert into user_lifecycle_audit_log (
run_uuid, action, trigger_type, status, reason_code,
policy_deactivate_days, policy_delete_days,
actor_user_id, target_user_id, target_user_uuid, target_user_email,
snapshot_enc, snapshot_version, created_at
) values (?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
(string) ($row['run_uuid'] ?? ''),
(string) ($row['action'] ?? ''),
(string) ($row['trigger_type'] ?? ''),
(string) ($row['status'] ?? ''),
$row['reason_code'] ?? null,
(string) ((int) ($row['policy_deactivate_days'] ?? 0)),
(string) ((int) ($row['policy_delete_days'] ?? 0)),
$row['actor_user_id'] !== null ? (string) ((int) $row['actor_user_id']) : null,
$row['target_user_id'] !== null ? (string) ((int) $row['target_user_id']) : null,
$row['target_user_uuid'] ?? null,
$row['target_user_email'] ?? null,
$row['snapshot_enc'] ?? null,
(string) ((int) ($row['snapshot_version'] ?? 1))
);
return $id ? (int) $id : false;
}
public static function updateStatus(int $id, string $status, ?string $reasonCode = null): bool
{
if ($id <= 0) {
return false;
}
$status = trim(strtolower($status));
if (!in_array($status, ['success', 'skipped', 'failed'], true)) {
return false;
}
$updated = DB::update(
'update user_lifecycle_audit_log set status = ?, reason_code = ? where id = ?',
$status,
$reasonCode,
(string) $id
);
return $updated !== false;
}
public static function listPaged(array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$action = strtolower(trim((string) ($filters['action'] ?? '')));
$status = strtolower(trim((string) ($filters['status'] ?? '')));
$triggerType = strtolower(trim((string) ($filters['trigger_type'] ?? '')));
$createdFrom = trim((string) ($filters['created_from'] ?? ''));
$createdTo = trim((string) ($filters['created_to'] ?? ''));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'created_at', 'action', 'trigger_type', 'status'],
'created_at',
'desc'
);
$where = [];
$params = [];
RepoQuery::addLikeFilter(
$where,
$params,
[
'user_lifecycle_audit_log.run_uuid',
'user_lifecycle_audit_log.target_user_uuid',
'user_lifecycle_audit_log.target_user_email',
'user_lifecycle_audit_log.reason_code',
],
$search
);
if (in_array($action, ['deactivate', 'delete', 'restore'], true)) {
$where[] = 'user_lifecycle_audit_log.action = ?';
$params[] = $action;
}
if (in_array($status, ['success', 'skipped', 'failed'], true)) {
$where[] = 'user_lifecycle_audit_log.status = ?';
$params[] = $status;
}
if (in_array($triggerType, ['manual', 'cron', 'system'], true)) {
$where[] = 'user_lifecycle_audit_log.trigger_type = ?';
$params[] = $triggerType;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) {
$where[] = 'user_lifecycle_audit_log.created_at >= ?';
$params[] = $createdFrom . ' 00:00:00';
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
$where[] = 'user_lifecycle_audit_log.created_at <= ?';
$params[] = $createdTo . ' 23:59:59';
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$fromSql = ' from user_lifecycle_audit_log ' .
'left join users actor_user on actor_user.id = user_lifecycle_audit_log.actor_user_id ' .
'left join users restored_by_user on restored_by_user.id = user_lifecycle_audit_log.restored_by_user_id ' .
'left join users restored_user on restored_user.id = user_lifecycle_audit_log.restored_user_id ';
$total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select
user_lifecycle_audit_log.id,
user_lifecycle_audit_log.run_uuid,
user_lifecycle_audit_log.action,
user_lifecycle_audit_log.trigger_type,
user_lifecycle_audit_log.status,
user_lifecycle_audit_log.reason_code,
user_lifecycle_audit_log.policy_deactivate_days,
user_lifecycle_audit_log.policy_delete_days,
user_lifecycle_audit_log.actor_user_id,
user_lifecycle_audit_log.target_user_id,
user_lifecycle_audit_log.target_user_uuid,
user_lifecycle_audit_log.target_user_email,
user_lifecycle_audit_log.snapshot_version,
user_lifecycle_audit_log.restored_at,
user_lifecycle_audit_log.restored_by_user_id,
user_lifecycle_audit_log.restored_user_id,
user_lifecycle_audit_log.created_at,
actor_user.uuid,
actor_user.display_name,
actor_user.email,
restored_by_user.uuid,
restored_by_user.display_name,
restored_by_user.email,
restored_user.uuid,
restored_user.display_name,
restored_user.email
' . $fromSql . $whereSql .
sprintf(' order by user_lifecycle_audit_log.`%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = self::normalizeRow($row, false);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return ['total' => $total, 'rows' => $normalized];
}
public static function find(int $id): ?array
{
if ($id <= 0) {
return null;
}
$row = DB::selectOne(
'select
user_lifecycle_audit_log.id,
user_lifecycle_audit_log.run_uuid,
user_lifecycle_audit_log.action,
user_lifecycle_audit_log.trigger_type,
user_lifecycle_audit_log.status,
user_lifecycle_audit_log.reason_code,
user_lifecycle_audit_log.policy_deactivate_days,
user_lifecycle_audit_log.policy_delete_days,
user_lifecycle_audit_log.actor_user_id,
user_lifecycle_audit_log.target_user_id,
user_lifecycle_audit_log.target_user_uuid,
user_lifecycle_audit_log.target_user_email,
user_lifecycle_audit_log.snapshot_enc,
user_lifecycle_audit_log.snapshot_version,
user_lifecycle_audit_log.restored_at,
user_lifecycle_audit_log.restored_by_user_id,
user_lifecycle_audit_log.restored_user_id,
user_lifecycle_audit_log.created_at,
actor_user.uuid,
actor_user.display_name,
actor_user.email,
restored_by_user.uuid,
restored_by_user.display_name,
restored_by_user.email,
restored_user.uuid,
restored_user.display_name,
restored_user.email
from user_lifecycle_audit_log
left join users actor_user on actor_user.id = user_lifecycle_audit_log.actor_user_id
left join users restored_by_user on restored_by_user.id = user_lifecycle_audit_log.restored_by_user_id
left join users restored_user on restored_user.id = user_lifecycle_audit_log.restored_user_id
where user_lifecycle_audit_log.id = ?
limit 1',
(string) $id
);
return self::normalizeRow($row, true);
}
public static function findDeleteEventForRestore(int $id, bool $forUpdate = false): ?array
{
if ($id <= 0) {
return null;
}
$query = 'select
id, run_uuid, action, trigger_type, status, reason_code,
policy_deactivate_days, policy_delete_days, actor_user_id,
target_user_id, target_user_uuid, target_user_email,
snapshot_enc, snapshot_version, restored_at,
restored_by_user_id, restored_user_id, created_at
from user_lifecycle_audit_log
where id = ?
and action = \'delete\'
and status = \'success\'
limit 1';
if ($forUpdate) {
$query .= ' for update';
}
$row = DB::selectOne($query, (string) $id);
if (!is_array($row)) {
return null;
}
$item = $row['user_lifecycle_audit_log'] ?? $row;
return is_array($item) ? $item : null;
}
public static function markRestored(int $id, int $restoredBy, int $restoredUserId): bool
{
if ($id <= 0 || $restoredBy <= 0 || $restoredUserId <= 0) {
return false;
}
$updated = DB::update(
'update user_lifecycle_audit_log
set restored_at = NOW(),
restored_by_user_id = ?,
restored_user_id = ?
where id = ? and restored_at is null',
(string) $restoredBy,
(string) $restoredUserId,
(string) $id
);
return (int) $updated > 0;
}
public static function purgeOlderThanDays(int $days): int
{
if ($days <= 0) {
return 0;
}
$cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('-' . $days . ' days')
->format('Y-m-d H:i:s');
$deleted = DB::delete('delete from user_lifecycle_audit_log where created_at < ?', $cutoff);
return is_int($deleted) ? $deleted : 0;
}
private static function normalizeRow(mixed $row, bool $includeSnapshot): ?array
{
if (!is_array($row)) {
return null;
}
$item = $row['user_lifecycle_audit_log'] ?? [];
if (!is_array($item) || !isset($item['id'])) {
return null;
}
$actor = is_array($row['actor_user'] ?? null) ? $row['actor_user'] : [];
$restoredBy = is_array($row['restored_by_user'] ?? null) ? $row['restored_by_user'] : [];
$restoredUser = is_array($row['restored_user'] ?? null) ? $row['restored_user'] : [];
$item['actor_user_uuid'] = (string) ($actor['uuid'] ?? '');
$item['actor_user_display_name'] = (string) ($actor['display_name'] ?? '');
$item['actor_user_email'] = (string) ($actor['email'] ?? '');
$item['restored_by_user_uuid'] = (string) ($restoredBy['uuid'] ?? '');
$item['restored_by_user_display_name'] = (string) ($restoredBy['display_name'] ?? '');
$item['restored_by_user_email'] = (string) ($restoredBy['email'] ?? '');
$item['restored_user_uuid'] = (string) ($restoredUser['uuid'] ?? '');
$item['restored_user_display_name'] = (string) ($restoredUser['display_name'] ?? '');
$item['restored_user_email'] = (string) ($restoredUser['email'] ?? '');
if (!$includeSnapshot) {
unset($item['snapshot_enc']);
}
return $item;
}
}

View File

@@ -0,0 +1,222 @@
<?php
namespace MintyPHP\Repository\Auth;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
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
{
if (!is_array($rows)) {
return [];
}
$list = [];
foreach ($rows as $row) {
$data = $row['user_api_tokens'] ?? $row;
if (is_array($data)) {
$list[] = $data;
}
}
return $list;
}
public static function isUuid(string $value): bool
{
return (bool) preg_match(self::UUID_REGEX, trim($value));
}
public static function create(
int $userId,
string $name,
string $selector,
string $tokenHash,
?int $tenantId,
?string $expiresAt,
?int $createdBy
): ?int {
$id = DB::insert(
'insert into user_api_tokens (uuid, user_id, name, selector, token_hash, tenant_id, expires_at, created_by, created) values (?,?,?,?,?,?,?,?,NOW())',
RepoQuery::uuidV4(),
(string) $userId,
$name,
$selector,
$tokenHash,
$tenantId !== null ? (string) $tenantId : null,
$expiresAt,
$createdBy !== null ? (string) $createdBy : null
);
return $id ? (int) $id : null;
}
public static 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',
$selector
);
if (!$row || !isset($row['user_api_tokens'])) {
return null;
}
return $row['user_api_tokens'];
}
public static 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',
(string) $id
);
if (!$row || !isset($row['user_api_tokens'])) {
return null;
}
return $row['user_api_tokens'];
}
public static function findByUuid(string $uuid): ?array
{
$uuid = trim($uuid);
if (!self::isUuid($uuid)) {
return null;
}
$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 uuid = ? limit 1',
$uuid
);
if (!$row || !isset($row['user_api_tokens'])) {
return null;
}
return $row['user_api_tokens'];
}
public static function findByUuidForUser(string $uuid, int $userId): ?array
{
$uuid = trim($uuid);
if ($userId <= 0 || !self::isUuid($uuid)) {
return null;
}
$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 uuid = ? and user_id = ? limit 1',
$uuid,
(string) $userId
);
if (!$row || !isset($row['user_api_tokens'])) {
return null;
}
return $row['user_api_tokens'];
}
public static function updateLastUsed(int $id, string $ip): bool
{
$result = DB::update(
'update user_api_tokens set last_used_at = NOW(), last_ip = ? where id = ?',
$ip,
(string) $id
);
return $result !== false;
}
public static function revoke(int $id): bool
{
$result = DB::update(
'update user_api_tokens set revoked_at = NOW() where id = ? and revoked_at is null',
(string) $id
);
return $result !== false;
}
public static function revokeByUuidForUser(string $uuid, int $userId): bool
{
$uuid = trim($uuid);
if ($userId <= 0 || !self::isUuid($uuid)) {
return false;
}
$result = DB::update(
'update user_api_tokens set revoked_at = NOW() where uuid = ? and user_id = ? and revoked_at is null',
$uuid,
(string) $userId
);
return $result !== false;
}
public static function revokeAllForUser(int $userId, ?int $tenantId = null): int
{
if ($tenantId !== null && $tenantId > 0) {
$result = DB::update(
'update user_api_tokens set revoked_at = NOW() where user_id = ? and (tenant_id = ? or tenant_id is null) and revoked_at is null',
(string) $userId,
(string) $tenantId
);
} else {
$result = DB::update(
'update user_api_tokens set revoked_at = NOW() where user_id = ? and revoked_at is null',
(string) $userId
);
}
return $result !== false ? (int) $result : 0;
}
public static 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())'
);
return $result !== false ? (int) $result : 0;
}
public static function listByUserId(int $userId, int $limit = 25): array
{
if ($userId <= 0) {
return [];
}
if ($limit < 1) {
$limit = 25;
} elseif ($limit > 100) {
$limit = 100;
}
$rows = DB::select(
'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 user_id = ? order by id desc limit ?',
(string) $userId,
(string) $limit
);
return self::unwrapList($rows);
}
public static 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())',
(string) $userId
);
return $count ? (int) $count : 0;
}
public static function countActiveForUserExcludingId(int $userId, int $excludeId): int
{
if ($userId <= 0) {
return 0;
}
$count = DB::selectValue(
'select count(*) from user_api_tokens where user_id = ? and id != ? and revoked_at is null and (expires_at is null or expires_at > NOW())',
(string) $userId,
(string) $excludeId
);
return $count ? (int) $count : 0;
}
public static 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())'
);
return $count ? (int) $count : 0;
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace MintyPHP\Repository\CustomField;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class TenantCustomFieldDefinitionRepository
{
private static function unwrap(?array $row): ?array
{
if (!$row) {
return null;
}
return $row['tenant_custom_field_definitions'] ?? null;
}
private static function unwrapList($rows): array
{
if (!is_array($rows)) {
return [];
}
$list = [];
foreach ($rows as $row) {
$item = $row['tenant_custom_field_definitions'] ?? null;
if (is_array($item)) {
$list[] = $item;
}
}
return $list;
}
public static function listByTenantId(int $tenantId, bool $onlyActive = true): array
{
if ($tenantId <= 0) {
return [];
}
$query = 'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
'from tenant_custom_field_definitions where tenant_id = ?';
$params = [(string) $tenantId];
if ($onlyActive) {
$query .= ' and active = 1';
}
$query .= ' order by sort_order asc, label asc, id asc';
return self::unwrapList(DB::select($query, ...$params));
}
public static function listByTenantIds(array $tenantIds, bool $onlyActive = true): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
if (!$tenantIds) {
return [];
}
$query = 'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
'from tenant_custom_field_definitions where tenant_id in (???)';
$params = [$tenantIds];
if ($onlyActive) {
$query .= ' and active = 1';
}
$query .= ' order by tenant_id asc, sort_order asc, label asc, id asc';
return self::unwrapList(call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $params)));
}
public static function listFilterableByTenantIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
if (!$tenantIds) {
return [];
}
$query = 'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
'from tenant_custom_field_definitions ' .
'where tenant_id in (???) and active = 1 and is_filterable = 1 ' .
"and type in ('select', 'multiselect', 'boolean', 'date') " .
'order by tenant_id asc, sort_order asc, label asc, id asc';
return self::unwrapList(call_user_func_array(['MintyPHP\\DB', 'select'], [$query, $tenantIds]));
}
public static function findByUuid(string $uuid): ?array
{
$row = DB::selectOne(
'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
'from tenant_custom_field_definitions where uuid = ? limit 1',
$uuid
);
return self::unwrap($row);
}
public static function findById(int $id): ?array
{
$row = DB::selectOne(
'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
'from tenant_custom_field_definitions where id = ? limit 1',
(string) $id
);
return self::unwrap($row);
}
public static function findByTenantIdAndKey(int $tenantId, string $fieldKey): ?array
{
if ($tenantId <= 0 || $fieldKey === '') {
return null;
}
$row = DB::selectOne(
'select id, uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, modified_by, created, modified ' .
'from tenant_custom_field_definitions where tenant_id = ? and field_key = ? limit 1',
(string) $tenantId,
$fieldKey
);
return self::unwrap($row);
}
public static function create(array $data): int|false
{
return DB::insert(
'insert into tenant_custom_field_definitions ' .
'(uuid, tenant_id, field_key, label, type, is_required, is_filterable, active, sort_order, created_by, created) ' .
'values (?,?,?,?,?,?,?,?,?,?,NOW())',
$data['uuid'] ?? RepoQuery::uuidV4(),
(string) ($data['tenant_id'] ?? 0),
(string) ($data['field_key'] ?? ''),
(string) ($data['label'] ?? ''),
(string) ($data['type'] ?? ''),
(string) ((int) ($data['is_required'] ?? 0)),
(string) ((int) ($data['is_filterable'] ?? 0)),
(string) ((int) ($data['active'] ?? 1)),
(string) ((int) ($data['sort_order'] ?? 100)),
$data['created_by'] ?? null
);
}
public static function update(int $id, array $data): bool
{
if ($id <= 0) {
return false;
}
$fields = [
'field_key' => (string) ($data['field_key'] ?? ''),
'label' => (string) ($data['label'] ?? ''),
'type' => (string) ($data['type'] ?? ''),
'is_required' => (int) ($data['is_required'] ?? 0),
'is_filterable' => (int) ($data['is_filterable'] ?? 0),
'active' => (int) ($data['active'] ?? 1),
'sort_order' => (int) ($data['sort_order'] ?? 100),
];
if (array_key_exists('modified_by', $data)) {
$fields['modified_by'] = $data['modified_by'];
}
$set = [];
$params = [];
foreach ($fields as $field => $value) {
$set[] = sprintf('`%s` = ?', $field);
$params[] = (string) $value;
}
$params[] = (string) $id;
$result = DB::update(
'update tenant_custom_field_definitions set ' . implode(', ', $set) . ' where id = ?',
...$params
);
return $result !== false;
}
public static function delete(int $id): bool
{
if ($id <= 0) {
return false;
}
$result = DB::delete('delete from tenant_custom_field_definitions where id = ?', (string) $id);
return $result !== false;
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace MintyPHP\Repository\CustomField;
use MintyPHP\DB;
class TenantCustomFieldOptionRepository
{
private static function unwrapList($rows): array
{
if (!is_array($rows)) {
return [];
}
$list = [];
foreach ($rows as $row) {
$item = $row['tenant_custom_field_options'] ?? null;
if (is_array($item)) {
$list[] = $item;
}
}
return $list;
}
public static function listByDefinitionIds(array $definitionIds, bool $onlyActive = true): array
{
$definitionIds = array_values(array_unique(array_map('intval', $definitionIds)));
$definitionIds = array_values(array_filter($definitionIds, static fn ($id) => $id > 0));
if (!$definitionIds) {
return [];
}
$query = 'select id, definition_id, option_key, label, active, sort_order, created, modified ' .
'from tenant_custom_field_options where definition_id in (???)';
$params = [$definitionIds];
if ($onlyActive) {
$query .= ' and active = 1';
}
$query .= ' order by definition_id asc, sort_order asc, label asc, id asc';
return self::unwrapList(call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $params)));
}
public static function replaceForDefinition(int $definitionId, array $options): bool
{
if ($definitionId <= 0) {
return false;
}
$existing = self::listByDefinitionIds([$definitionId], false);
$existingByKey = [];
foreach ($existing as $row) {
$key = (string) ($row['option_key'] ?? '');
if ($key !== '') {
$existingByKey[$key] = $row;
}
}
$keepIds = [];
foreach ($options as $option) {
if (!is_array($option)) {
continue;
}
$optionKey = trim((string) ($option['option_key'] ?? ''));
$label = trim((string) ($option['label'] ?? ''));
if ($optionKey === '' || $label === '') {
continue;
}
$active = !empty($option['active']) ? 1 : 0;
$sortOrder = (int) ($option['sort_order'] ?? 100);
if (isset($existingByKey[$optionKey]['id'])) {
$optionId = (int) $existingByKey[$optionKey]['id'];
$keepIds[] = $optionId;
$updated = DB::update(
'update tenant_custom_field_options set label = ?, active = ?, sort_order = ? where id = ?',
$label,
(string) $active,
(string) $sortOrder,
(string) $optionId
);
if ($updated === false) {
return false;
}
continue;
}
$insertId = DB::insert(
'insert into tenant_custom_field_options (definition_id, option_key, label, active, sort_order, created) values (?,?,?,?,?,NOW())',
(string) $definitionId,
$optionKey,
$label,
(string) $active,
(string) $sortOrder
);
if ($insertId === false) {
return false;
}
if ($insertId) {
$keepIds[] = (int) $insertId;
}
}
if (!$keepIds) {
$deleted = DB::delete('delete from tenant_custom_field_options where definition_id = ?', (string) $definitionId);
if ($deleted === false) {
return false;
}
return true;
}
$deleted = DB::delete(
'delete from tenant_custom_field_options where definition_id = ? and id not in (???)',
(string) $definitionId,
$keepIds
);
if ($deleted === false) {
return false;
}
return true;
}
public static function deleteByDefinitionId(int $definitionId): bool
{
if ($definitionId <= 0) {
return false;
}
$result = DB::delete('delete from tenant_custom_field_options where definition_id = ?', (string) $definitionId);
return $result !== false;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace MintyPHP\Repository\CustomField;
use MintyPHP\DB;
class UserCustomFieldValueOptionRepository
{
public static function replaceForValueId(int $valueId, array $optionIds): bool
{
if ($valueId <= 0) {
return false;
}
$deleted = DB::delete('delete from user_custom_field_value_options where value_id = ?', (string) $valueId);
if ($deleted === false) {
return false;
}
$optionIds = array_values(array_unique(array_map('intval', $optionIds)));
$optionIds = array_values(array_filter($optionIds, static fn ($id) => $id > 0));
if (!$optionIds) {
return true;
}
foreach ($optionIds as $optionId) {
$inserted = DB::insert(
'insert into user_custom_field_value_options (value_id, option_id, created) values (?,?,NOW())',
(string) $valueId,
(string) $optionId
);
if ($inserted === false) {
return false;
}
}
return true;
}
public static function listOptionIdsByValueIds(array $valueIds): array
{
$valueIds = array_values(array_unique(array_map('intval', $valueIds)));
$valueIds = array_values(array_filter($valueIds, static fn ($id) => $id > 0));
if (!$valueIds) {
return [];
}
$rows = DB::select(
'select value_id, option_id from user_custom_field_value_options where value_id in (???)',
$valueIds
);
if (!is_array($rows)) {
return [];
}
$map = [];
foreach ($rows as $row) {
$data = $row['user_custom_field_value_options'] ?? $row;
if (!is_array($data)) {
continue;
}
$valueId = (int) ($data['value_id'] ?? 0);
$optionId = (int) ($data['option_id'] ?? 0);
if ($valueId <= 0 || $optionId <= 0) {
continue;
}
$map[$valueId] ??= [];
$map[$valueId][] = $optionId;
}
foreach ($map as &$ids) {
$ids = array_values(array_unique(array_map('intval', $ids)));
sort($ids, SORT_NUMERIC);
}
unset($ids);
return $map;
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace MintyPHP\Repository\CustomField;
use MintyPHP\DB;
class UserCustomFieldValueRepository
{
private static function unwrapList($rows): array
{
if (!is_array($rows)) {
return [];
}
$list = [];
foreach ($rows as $row) {
$item = $row['user_custom_field_values'] ?? null;
if (is_array($item)) {
$list[] = $item;
}
}
return $list;
}
public static function listByUserAndDefinitionIds(int $userId, array $definitionIds): array
{
if ($userId <= 0) {
return [];
}
$definitionIds = array_values(array_unique(array_map('intval', $definitionIds)));
$definitionIds = array_values(array_filter($definitionIds, static fn ($id) => $id > 0));
if (!$definitionIds) {
return [];
}
$rows = DB::select(
'select id, user_id, definition_id, value_text, value_bool, value_date, option_id, created, modified ' .
'from user_custom_field_values where user_id = ? and definition_id in (???)',
(string) $userId,
$definitionIds
);
return self::unwrapList($rows);
}
public static function upsertScalarValue(int $userId, int $definitionId, array $typedValue): int|false
{
if ($userId <= 0 || $definitionId <= 0) {
return false;
}
$existingId = DB::selectValue(
'select id from user_custom_field_values where user_id = ? and definition_id = ? limit 1',
(string) $userId,
(string) $definitionId
);
$valueText = array_key_exists('value_text', $typedValue) ? $typedValue['value_text'] : null;
$valueBool = array_key_exists('value_bool', $typedValue) ? $typedValue['value_bool'] : null;
$valueDate = array_key_exists('value_date', $typedValue) ? $typedValue['value_date'] : null;
$optionId = array_key_exists('option_id', $typedValue) ? $typedValue['option_id'] : null;
if ($existingId) {
$updated = DB::update(
'update user_custom_field_values set value_text = ?, value_bool = ?, value_date = ?, option_id = ? where id = ?',
$valueText,
$valueBool !== null ? (string) ((int) $valueBool) : null,
$valueDate,
$optionId !== null ? (string) ((int) $optionId) : null,
(string) ((int) $existingId)
);
return $updated !== false ? (int) $existingId : false;
}
return DB::insert(
'insert into user_custom_field_values (user_id, definition_id, value_text, value_bool, value_date, option_id, created) values (?,?,?,?,?,?,NOW())',
(string) $userId,
(string) $definitionId,
$valueText,
$valueBool !== null ? (string) ((int) $valueBool) : null,
$valueDate,
$optionId !== null ? (string) ((int) $optionId) : null
);
}
public static function deleteByUserAndDefinitionIds(int $userId, array $definitionIds): bool
{
if ($userId <= 0) {
return false;
}
$definitionIds = array_values(array_unique(array_map('intval', $definitionIds)));
$definitionIds = array_values(array_filter($definitionIds, static fn ($id) => $id > 0));
if (!$definitionIds) {
return true;
}
$result = DB::delete(
'delete from user_custom_field_values where user_id = ? and definition_id in (???)',
(string) $userId,
$definitionIds
);
return $result !== false;
}
public static function deleteByUserOutsideTenantIds(int $userId, array $tenantIds): bool
{
if ($userId <= 0) {
return false;
}
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
if (!$tenantIds) {
$result = DB::delete('delete from user_custom_field_values where user_id = ?', (string) $userId);
return $result !== false;
}
$result = DB::delete(
'delete ucfv from user_custom_field_values ucfv ' .
'join tenant_custom_field_definitions d on d.id = ucfv.definition_id ' .
'where ucfv.user_id = ? and d.tenant_id not in (???)',
(string) $userId,
$tenantIds
);
return $result !== false;
}
}

View File

@@ -33,7 +33,7 @@ class DepartmentRepository
public static function list(): array
{
$rows = DB::select(
'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments order by id desc'
'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);
}
@@ -54,6 +54,31 @@ class DepartmentRepository
return array_values(array_unique($ids));
}
public static function listActiveIdsByTenantIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0);
if (!$tenantIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
$rows = DB::select(
'select id from departments where active = 1 and tenant_id in (' . $placeholders . ')',
...array_map('strval', $tenantIds)
);
if (!is_array($rows)) {
return [];
}
$ids = [];
foreach ($rows as $row) {
$data = $row['departments'] ?? $row;
if (is_array($data) && isset($data['id'])) {
$ids[] = (int) $data['id'];
}
}
return array_values(array_unique($ids));
}
public static function listByTenantIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
@@ -63,17 +88,16 @@ class DepartmentRepository
}
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
$rows = DB::select(
'select departments.id, departments.uuid, departments.description, departments.code, departments.cost_center, departments.active, departments.created_by, departments.modified_by, departments.created, departments.modified ' .
'from departments departments join tenant_departments td on td.department_id = departments.id ' .
'where departments.active = 1 and td.tenant_id in (' . $placeholders . ') ' .
'group by departments.id, departments.uuid, departments.description, departments.code, departments.cost_center, departments.active, departments.created_by, departments.modified_by, departments.created, departments.modified ' .
'select departments.id, departments.uuid, departments.description, departments.tenant_id, departments.code, departments.cost_center, departments.active, departments.created_by, departments.modified_by, departments.created, departments.modified ' .
"from departments departments join tenants t on t.id = departments.tenant_id and t.status = 'active' " .
'where departments.active = 1 and departments.tenant_id in (' . $placeholders . ') ' .
'order by departments.description asc',
...array_map('strval', $tenantIds)
);
return self::unwrapList($rows);
}
public static function listByIds(array $departmentIds): array
public static 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);
@@ -81,8 +105,9 @@ class DepartmentRepository
return [];
}
$placeholders = implode(',', array_fill(0, count($departmentIds), '?'));
$activeSql = $includeInactive ? '' : 'active = 1 and ';
$rows = DB::select(
'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments where active = 1 and id in (' . $placeholders . ') order by description asc',
'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);
@@ -113,44 +138,25 @@ class DepartmentRepository
$where,
$params,
$tenant,
"exists (select 1 from tenant_departments td join tenants t on t.id = td.tenant_id and t.status = 'active' where td.department_id = departments.id and t.uuid = ?)"
"exists (select 1 from tenants t where t.id = departments.tenant_id and t.status = 'active' and t.uuid = ?)"
);
if (!empty($options['tenantUserId'])) {
$tenantUserId = (int) $options['tenantUserId'];
if ($tenantUserId > 0) {
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
$where[] = 'exists (select 1 from tenant_departments td ' .
"join tenants t on t.id = td.tenant_id and t.status = 'active' " .
'join user_tenants ut on ut.tenant_id = td.tenant_id ' .
'where ut.user_id = ? and td.department_id = departments.id)';
$params[] = (string) $tenantUserId;
} else {
$where[] = '(not exists (select 1 from tenant_departments td where td.department_id = departments.id) ' .
'or exists (select 1 from tenant_departments td ' .
"join tenants t on t.id = td.tenant_id and t.status = 'active' " .
'join user_tenants ut on ut.tenant_id = td.tenant_id ' .
'where ut.user_id = ? and td.department_id = departments.id))';
$params[] = (string) $tenantUserId;
}
$where[] = 'exists (select 1 from user_tenants ut ' .
"join tenants t on t.id = ut.tenant_id and t.status = 'active' " .
'where ut.user_id = ? and ut.tenant_id = departments.tenant_id)';
$params[] = (string) $tenantUserId;
}
} elseif (array_key_exists('tenantIds', $options)) {
$tenantIds = $options['tenantIds'];
$tenantIds = is_array($tenantIds) ? array_values(array_unique(array_map('intval', $tenantIds))) : [];
if ($tenantIds) {
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
$where[] = 'exists (select 1 from tenant_departments td where td.department_id = departments.id and td.tenant_id in (???))';
$params[] = $tenantIds;
} else {
$where[] = '(not exists (select 1 from tenant_departments td where td.department_id = departments.id) ' .
'or exists (select 1 from tenant_departments td where td.department_id = departments.id and td.tenant_id in (???)))';
$params[] = $tenantIds;
}
$where[] = 'departments.tenant_id in (???)';
$params[] = $tenantIds;
} else {
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
$where[] = '1=0';
} else {
$where[] = 'not exists (select 1 from tenant_departments td where td.department_id = departments.id)';
}
$where[] = '1=0';
}
}
@@ -158,7 +164,7 @@ class DepartmentRepository
$count = DB::selectValue('select count(*) from departments' . $whereSql, ...$params);
$total = $count ? (int) $count : 0;
$query = 'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments' .
$query = 'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments' .
$whereSql .
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
@@ -176,48 +182,28 @@ class DepartmentRepository
if ($ids) {
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$labelRows = DB::select(
"select td.department_id as department_id, t.description as description from tenant_departments td join tenants t on t.id = td.tenant_id and t.status = 'active' " .
'where td.department_id in (' . $placeholders . ') order by t.description asc',
'select d.id as department_id, t.description as description from departments d join tenants t on t.id = d.tenant_id ' .
'where d.id in (' . $placeholders . ') order by t.description asc',
...array_map('strval', $ids)
);
$collectLabels = static function (array $rows): array {
$labelsByDepartment = [];
foreach ($rows as $row) {
$departmentId = 0;
$label = '';
if (isset($row['td']) && is_array($row['td'])) {
$departmentId = (int) ($row['td']['department_id'] ?? 0);
foreach ((array) $labelRows as $row) {
$departmentId = 0;
$label = '';
foreach ((array) $row as $table) {
if (!is_array($table)) {
continue;
}
if (isset($row['t']) && is_array($row['t'])) {
$label = (string) ($row['t']['description'] ?? '');
if ($departmentId === 0 && isset($table['department_id'])) {
$departmentId = (int) $table['department_id'];
}
if ($departmentId === 0 || $label === '') {
foreach ($row as $value) {
if (!is_array($value)) {
continue;
}
if ($departmentId === 0 && isset($value['department_id'])) {
$departmentId = (int) $value['department_id'];
}
if ($label === '' && isset($value['description'])) {
$label = (string) $value['description'];
}
}
}
if ($departmentId === 0 && isset($row['department_id'])) {
$departmentId = (int) $row['department_id'];
}
if ($label === '' && isset($row['description'])) {
$label = (string) $row['description'];
}
if ($departmentId > 0 && $label !== '') {
$labelsByDepartment[$departmentId] ??= [];
$labelsByDepartment[$departmentId][] = $label;
if ($label === '' && isset($table['description'])) {
$label = (string) $table['description'];
}
}
return $labelsByDepartment;
};
$tenantLabelsByDepartment = $collectLabels($labelRows);
if ($departmentId > 0 && $label !== '') {
$tenantLabelsByDepartment[$departmentId] = [$label];
}
}
}
foreach ($list as &$department) {
$departmentId = (int) ($department['id'] ?? 0);
@@ -234,7 +220,7 @@ class DepartmentRepository
public static function find(int $id): ?array
{
$row = DB::selectOne(
'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments where id = ? limit 1',
'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);
@@ -243,7 +229,7 @@ class DepartmentRepository
public static function findByUuid(string $uuid): ?array
{
$row = DB::selectOne(
'select id, uuid, description, code, cost_center, active, created_by, modified_by, created, modified from departments where uuid = ? limit 1',
'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);
@@ -263,20 +249,48 @@ class DepartmentRepository
return (int) $count > 0;
}
private static function uuidV4(): string
public static function existsByTenantAndDescription(int $tenantId, string $description, int $excludeId = 0): bool
{
$data = random_bytes(16);
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
$tenantId = (int) $tenantId;
$description = trim($description);
if ($tenantId <= 0 || $description === '') {
return false;
}
if ($excludeId > 0) {
$count = DB::selectValue(
'select count(*) from departments where tenant_id = ? and lower(description) = lower(?) and id != ?',
(string) $tenantId,
$description,
(string) $excludeId
);
} else {
$count = DB::selectValue(
'select count(*) from departments where tenant_id = ? and lower(description) = lower(?)',
(string) $tenantId,
$description
);
}
return (int) $count > 0;
}
public static function countByTenantId(int $tenantId): int
{
if ($tenantId <= 0) {
return 0;
}
$count = DB::selectValue('select count(*) from departments where tenant_id = ?', (string) $tenantId);
return $count ? (int) $count : 0;
}
public static function create(array $data)
{
return DB::insert(
'insert into departments (uuid, description, code, cost_center, active, created_by, created) values (?,?,?,?,?,?,NOW())',
$data['uuid'] ?? self::uuidV4(),
'insert into departments (uuid, description, tenant_id, code, cost_center, active, created_by, created) values (?,?,?,?,?,?,?,NOW())',
$data['uuid'] ?? RepoQuery::uuidV4(),
$data['description'],
(string) ($data['tenant_id'] ?? 0),
$data['code'] ?? null,
$data['cost_center'] ?? null,
$data['active'] ?? 1,
@@ -288,6 +302,7 @@ class DepartmentRepository
{
$fields = [
'description' => $data['description'],
'tenant_id' => (string) ($data['tenant_id'] ?? 0),
'code' => $data['code'] ?? null,
'cost_center' => $data['cost_center'] ?? null,
'active' => $data['active'] ?? 1,
@@ -309,6 +324,19 @@ class DepartmentRepository
return $result !== false;
}
public static function setTenant(int $id, int $tenantId): bool
{
if ($id <= 0 || $tenantId <= 0) {
return false;
}
$result = DB::update(
'update departments set tenant_id = ? where id = ?',
(string) $tenantId,
(string) $id
);
return $result !== false;
}
public static function delete(int $id): bool
{
$result = DB::delete('delete from departments where id = ?', (string) $id);

View File

@@ -45,10 +45,69 @@ class UserDepartmentRepository
$query = 'delete ud from user_departments ud ' .
'where ud.department_id = ? and not exists (' .
'select 1 from user_tenants ut ' .
'join tenant_departments td on td.tenant_id = ut.tenant_id ' .
'where ut.user_id = ud.user_id and td.department_id = ud.department_id' .
'join departments d on d.id = ud.department_id ' .
'where ut.user_id = ud.user_id and ut.tenant_id = d.tenant_id' .
')';
$result = DB::delete($query, (string) $departmentId);
return $result !== false ? (int) $result : 0;
}
public static function countUsersByDepartmentIds(array $departmentIds): array
{
return self::countByDepartmentIds($departmentIds, false);
}
public static function countActiveUsersByDepartmentIds(array $departmentIds): array
{
return self::countByDepartmentIds($departmentIds, true);
}
private static 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));
if (!$departmentIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($departmentIds), '?'));
$joinUsersSql = $activeOnly ? ' join users u on u.id = ud.user_id and u.active = 1' : '';
$rows = DB::select(
'select ud.department_id, count(distinct ud.user_id) as user_count from user_departments ud' .
$joinUsersSql .
' where ud.department_id in (' . $placeholders . ') group by ud.department_id',
...array_map('strval', $departmentIds)
);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$departmentId = self::extractIntField($row, 'department_id');
if ($departmentId <= 0) {
continue;
}
$result[$departmentId] = self::extractIntField($row, 'user_count');
}
return $result;
}
private static function extractIntField(array $row, string $field): int
{
foreach ($row as $value) {
if (!is_array($value)) {
continue;
}
if (array_key_exists($field, $value)) {
return (int) $value[$field];
}
}
if (array_key_exists($field, $row)) {
return (int) $row[$field];
}
return 0;
}
}

View File

@@ -0,0 +1,252 @@
<?php
namespace MintyPHP\Repository\Scheduler;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class ScheduledJobRepository
{
public static function create(array $data): int|false
{
$id = DB::insert(
'insert into scheduled_jobs (
job_key, label, description, enabled, timezone,
schedule_type, schedule_interval, schedule_time, schedule_weekdays_csv,
catchup_once, next_run_at
) values (?,?,?,?,?,?,?,?,?,?,?)',
(string) ($data['job_key'] ?? ''),
(string) ($data['label'] ?? ''),
$data['description'] ?? null,
(string) ((int) ($data['enabled'] ?? 0)),
(string) ($data['timezone'] ?? ''),
(string) ($data['schedule_type'] ?? 'daily'),
(string) ((int) ($data['schedule_interval'] ?? 1)),
$data['schedule_time'] ?? null,
$data['schedule_weekdays_csv'] ?? null,
(string) ((int) ($data['catchup_once'] ?? 1)),
$data['next_run_at'] ?? null
);
return $id ? (int) $id : false;
}
public static 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);
}
public static 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);
}
public static function listPaged(array $filters): array
{
$search = trim((string) ($filters['search'] ?? ''));
$enabled = trim((string) ($filters['enabled'] ?? ''));
$status = strtolower(trim((string) ($filters['status'] ?? '')));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'job_key', 'label', 'enabled', 'next_run_at', 'last_run_finished_at', 'last_run_status', 'updated_at'],
'job_key',
'asc'
);
$where = [];
$params = [];
RepoQuery::addLikeFilter(
$where,
$params,
['job_key', 'label', 'description', 'last_error_code', 'last_error_message'],
$search
);
if (in_array($enabled, ['0', '1'], true)) {
$where[] = 'enabled = ?';
$params[] = $enabled;
}
if (in_array($status, ['success', 'failed', 'running', 'skipped'], true)) {
$where[] = 'last_run_status = ?';
$params[] = $status;
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$total = (int) (DB::selectValue('select count(*) from scheduled_jobs' . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select * from scheduled_jobs' .
$whereSql .
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = self::normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return [
'total' => $total,
'rows' => $normalized,
];
}
public static function listDueJobs(string $nowUtc, int $limit = 20): array
{
$limit = max(1, min(200, $limit));
$rows = DB::select(
'select * from scheduled_jobs
where enabled = 1
and next_run_at is not null
and next_run_at <= ?
order by next_run_at asc
limit ?',
$nowUtc,
(string) $limit
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = self::normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return $normalized;
}
public static function updateJobMeta(int $id, array $data): bool
{
if ($id <= 0) {
return false;
}
$updated = DB::update(
'update scheduled_jobs
set label = ?,
description = ?,
enabled = ?,
timezone = ?,
schedule_type = ?,
schedule_interval = ?,
schedule_time = ?,
schedule_weekdays_csv = ?,
catchup_once = ?,
next_run_at = ?
where id = ?',
(string) ($data['label'] ?? ''),
$data['description'] ?? null,
(string) ((int) ($data['enabled'] ?? 0)),
(string) ($data['timezone'] ?? ''),
(string) ($data['schedule_type'] ?? 'daily'),
(string) ((int) ($data['schedule_interval'] ?? 1)),
$data['schedule_time'] ?? null,
$data['schedule_weekdays_csv'] ?? null,
(string) ((int) ($data['catchup_once'] ?? 1)),
$data['next_run_at'] ?? null,
(string) $id
);
return $updated !== false;
}
/**
* Atomically marks a job as running if it is not already in a running state.
*
* Includes stale-running protection: if a job has been stuck in 'running' status
* for more than 2 hours, it is considered abandoned (e.g. the runner process died)
* and a new run is allowed to proceed. This prevents a crashed runner from blocking
* the job permanently.
*
* 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
{
if ($id <= 0) {
return false;
}
// A job that has been 'running' for longer than this threshold is treated as stale.
$staleBefore = (new \DateTimeImmutable($startedAtUtc, new \DateTimeZone('UTC')))
->modify('-2 hours')
->format('Y-m-d H:i:s');
$updated = DB::update(
'update scheduled_jobs
set last_run_status = ?,
last_run_started_at = ?,
last_run_finished_at = null,
last_error_code = null,
last_error_message = null
where id = ?
and (last_run_status is null
or last_run_status <> ?
or (last_run_started_at is not null and last_run_started_at < ?))',
'running',
$startedAtUtc,
(string) $id,
'running',
$staleBefore
);
return (int) $updated > 0;
}
public static function finishRun(
int $id,
string $status,
string $startedAtUtc,
string $finishedAtUtc,
?string $nextRunAtUtc,
?string $errorCode,
?string $errorMessage
): bool {
if ($id <= 0) {
return false;
}
$updated = DB::update(
'update scheduled_jobs
set last_run_status = ?,
last_run_started_at = ?,
last_run_finished_at = ?,
next_run_at = ?,
last_error_code = ?,
last_error_message = ?
where id = ?',
$status,
$startedAtUtc,
$finishedAtUtc,
$nextRunAtUtc,
$errorCode,
$errorMessage,
(string) $id
);
return $updated !== false;
}
private static function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$item = $row['scheduled_jobs'] ?? $row;
if (!is_array($item) || !isset($item['id'])) {
return null;
}
return $item;
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace MintyPHP\Repository\Scheduler;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class ScheduledJobRunRepository
{
public static function create(array $data): int|false
{
$id = DB::insert(
'insert into scheduled_job_runs (
run_uuid, job_id, job_key, trigger_type, status, actor_user_id,
started_at, finished_at, duration_ms, error_code, error_message, result_json
) values (?,?,?,?,?,?,?,?,?,?,?,?)',
(string) ($data['run_uuid'] ?? ''),
(string) ((int) ($data['job_id'] ?? 0)),
(string) ($data['job_key'] ?? ''),
(string) ($data['trigger_type'] ?? 'scheduler'),
(string) ($data['status'] ?? 'failed'),
($data['actor_user_id'] ?? null) !== null ? (string) ((int) $data['actor_user_id']) : null,
(string) ($data['started_at'] ?? ''),
$data['finished_at'] ?? null,
($data['duration_ms'] ?? null) !== null ? (string) ((int) $data['duration_ms']) : null,
$data['error_code'] ?? null,
$data['error_message'] ?? null,
$data['result_json'] ?? null
);
return $id ? (int) $id : false;
}
public static function listPagedByJobId(int $jobId, array $filters): array
{
if ($jobId <= 0) {
return ['total' => 0, 'rows' => []];
}
$search = trim((string) ($filters['search'] ?? ''));
$status = strtolower(trim((string) ($filters['status'] ?? '')));
$triggerType = strtolower(trim((string) ($filters['trigger_type'] ?? '')));
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($filters, 20, 1, 200, 0);
[$order, $dir] = RepoQuery::sanitizeOrder(
$filters,
['id', 'started_at', 'finished_at', 'duration_ms', 'status', 'trigger_type'],
'started_at',
'desc'
);
$where = ['scheduled_job_runs.job_id = ?'];
$params = [(string) $jobId];
RepoQuery::addLikeFilter(
$where,
$params,
['scheduled_job_runs.run_uuid', 'scheduled_job_runs.error_code', 'scheduled_job_runs.error_message'],
$search
);
if (in_array($status, ['success', 'failed', 'skipped'], true)) {
$where[] = 'scheduled_job_runs.status = ?';
$params[] = $status;
}
if (in_array($triggerType, ['scheduler', 'manual'], true)) {
$where[] = 'scheduled_job_runs.trigger_type = ?';
$params[] = $triggerType;
}
$whereSql = ' where ' . implode(' and ', $where);
$fromSql = ' from scheduled_job_runs left join users on users.id = scheduled_job_runs.actor_user_id ';
$total = (int) (DB::selectValue('select count(*)' . $fromSql . $whereSql, ...$params) ?? 0);
$rows = DB::select(
'select
scheduled_job_runs.id,
scheduled_job_runs.run_uuid,
scheduled_job_runs.job_id,
scheduled_job_runs.job_key,
scheduled_job_runs.trigger_type,
scheduled_job_runs.status,
scheduled_job_runs.actor_user_id,
scheduled_job_runs.started_at,
scheduled_job_runs.finished_at,
scheduled_job_runs.duration_ms,
scheduled_job_runs.error_code,
scheduled_job_runs.error_message,
scheduled_job_runs.result_json,
scheduled_job_runs.created_at,
users.id,
users.uuid,
users.display_name,
users.email
' . $fromSql . $whereSql .
sprintf(' order by scheduled_job_runs.`%s` %s limit ? offset ?', $order, $dir),
...array_merge($params, [(string) $limit, (string) $offset])
);
$normalized = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$item = self::normalizeRow($row);
if ($item !== null) {
$normalized[] = $item;
}
}
}
return ['total' => $total, 'rows' => $normalized];
}
public static function purgeOlderThanDays(int $days): int
{
if ($days <= 0) {
return 0;
}
$cutoff = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
->modify('-' . $days . ' days')
->format('Y-m-d H:i:s');
$deleted = DB::delete('delete from scheduled_job_runs where created_at < ?', $cutoff);
return is_int($deleted) ? $deleted : 0;
}
private static function normalizeRow(mixed $row): ?array
{
if (!is_array($row)) {
return null;
}
$item = $row['scheduled_job_runs'] ?? [];
if (!is_array($item) || !isset($item['id'])) {
return null;
}
$user = is_array($row['users'] ?? null) ? $row['users'] : [];
$item['actor_user_uuid'] = (string) ($user['uuid'] ?? '');
$item['actor_user_display_name'] = (string) ($user['display_name'] ?? '');
$item['actor_user_email'] = (string) ($user['email'] ?? '');
return $item;
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace MintyPHP\Repository\Scheduler;
use MintyPHP\DB;
class SchedulerRuntimeRepository
{
public static function touchHeartbeat(string $result, ?string $errorCode = null, ?string $heartbeatAtUtc = null): bool
{
$heartbeatAtUtc = trim((string) ($heartbeatAtUtc ?? ''));
if ($heartbeatAtUtc === '') {
$heartbeatAtUtc = gmdate('Y-m-d H:i:s');
}
$result = trim($result);
if ($result === '') {
$result = 'ok';
}
$errorCode = self::nullableTrim($errorCode);
$updated = DB::update(
'insert into scheduler_runtime_status (id, last_heartbeat_at, last_result, last_error_code) ' .
'values (1, ?, ?, ?) ' .
'on duplicate key update ' .
'last_heartbeat_at = values(last_heartbeat_at), ' .
'last_result = values(last_result), ' .
'last_error_code = values(last_error_code)',
$heartbeatAtUtc,
$result,
$errorCode
);
return $updated !== false;
}
public static function findStatus(): ?array
{
$row = DB::selectOne('select * from scheduler_runtime_status where id = 1 limit 1');
if (!is_array($row)) {
return null;
}
$item = $row['scheduler_runtime_status'] ?? $row;
return is_array($item) ? $item : null;
}
private static function nullableTrim(?string $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace MintyPHP\Repository\Security;
use MintyPHP\DB;
class RateLimitRepository
{
public static 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',
$scope,
$subjectHash
);
if (!$row || !isset($row['request_rate_limits']) || !is_array($row['request_rate_limits'])) {
return null;
}
return $row['request_rate_limits'];
}
public static function create(
string $scope,
string $subjectHash,
int $hits,
string $windowStartedAt,
?string $blockedUntil
): bool {
$result = DB::insert(
'insert into request_rate_limits (scope, subject_hash, hits, window_started_at, blocked_until, created) values (?,?,?,?,?,NOW())',
$scope,
$subjectHash,
(string) max(0, $hits),
$windowStartedAt,
$blockedUntil
);
return $result !== false;
}
public static function updateStateById(
int $id,
int $hits,
string $windowStartedAt,
?string $blockedUntil
): bool {
if ($id <= 0) {
return false;
}
$result = DB::update(
'update request_rate_limits set hits = ?, window_started_at = ?, blocked_until = ? where id = ?',
(string) max(0, $hits),
$windowStartedAt,
$blockedUntil,
(string) $id
);
return $result !== false;
}
public static function deleteByScopeAndHash(string $scope, string $subjectHash): bool
{
$result = DB::delete(
'delete from request_rate_limits where scope = ? and subject_hash = ?',
$scope,
$subjectHash
);
return $result !== false;
}
}

View File

@@ -104,6 +104,14 @@ class RepoQuery
}
}
public static function uuidV4(): string
{
$data = random_bytes(16);
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
public static function addEqualsFilter(array &$where, array &$params, $value, string $sql, bool $allowEmpty = false): void
{
// Use for simple "= ?" filters; skip when empty to keep WHERE clean.
@@ -117,4 +125,27 @@ class RepoQuery
$where[] = $sql;
$params[] = $normalized;
}
public static function normalizeIdList($value): array
{
if (is_string($value)) {
$value = array_filter(array_map('trim', explode(',', $value)));
} elseif (!is_array($value)) {
return [];
}
$flat = [];
array_walk_recursive($value, static function ($item) use (&$flat): void {
$flat[] = $item;
});
$ids = [];
foreach ($flat as $item) {
$id = (int) trim((string) $item);
if ($id > 0) {
$ids[] = $id;
}
}
$ids = array_values(array_unique($ids));
sort($ids, SORT_NUMERIC);
return $ids;
}
}

View File

@@ -1,102 +0,0 @@
<?php
namespace MintyPHP\Repository\Tenant;
use MintyPHP\DB;
class TenantDepartmentRepository
{
public static function listTenantIdsByDepartmentId(int $departmentId): array
{
$rows = DB::select('select tenant_id from tenant_departments where department_id = ?', (string) $departmentId);
if (!is_array($rows)) {
return [];
}
$ids = [];
foreach ($rows as $row) {
$data = $row['tenant_departments'] ?? $row;
if (is_array($data) && isset($data['tenant_id'])) {
$ids[] = (int) $data['tenant_id'];
}
}
return array_values(array_unique($ids));
}
public static function listDepartmentIdsByTenantIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0);
if (!$tenantIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
$rows = DB::select(
'select department_id from tenant_departments where tenant_id in (' . $placeholders . ')',
...array_map('strval', $tenantIds)
);
if (!is_array($rows)) {
return [];
}
$ids = [];
foreach ($rows as $row) {
$data = $row['tenant_departments'] ?? $row;
if (is_array($data) && isset($data['department_id'])) {
$ids[] = (int) $data['department_id'];
}
}
return array_values(array_unique($ids));
}
public static function listDepartmentMapByTenantIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0);
if (!$tenantIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
$rows = DB::select(
'select tenant_id, department_id from tenant_departments where tenant_id in (' . $placeholders . ')',
...array_map('strval', $tenantIds)
);
if (!is_array($rows)) {
return [];
}
$map = [];
foreach ($rows as $row) {
$data = $row['tenant_departments'] ?? $row;
if (is_array($data) && isset($data['tenant_id'], $data['department_id'])) {
$tenantId = (int) $data['tenant_id'];
$departmentId = (int) $data['department_id'];
if ($tenantId > 0 && $departmentId > 0) {
$map[$tenantId] ??= [];
$map[$tenantId][] = $departmentId;
}
}
}
foreach ($map as $tenantId => $items) {
$map[$tenantId] = array_values(array_unique($items));
}
return $map;
}
public static function replaceForDepartment(int $departmentId, array $tenantIds): bool
{
DB::delete('delete from tenant_departments where department_id = ?', (string) $departmentId);
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
$tenantIds = array_filter($tenantIds, static fn ($id) => $id > 0);
if (!$tenantIds) {
return true;
}
foreach ($tenantIds as $tenantId) {
DB::insert(
'insert into tenant_departments (tenant_id, department_id, created) values (?,?,NOW())',
(string) $tenantId,
(string) $departmentId
);
}
return true;
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace MintyPHP\Repository\Tenant;
use MintyPHP\DB;
class TenantMicrosoftAuthRepository
{
private static function unwrap($row): ?array
{
if (!$row || !is_array($row)) {
return null;
}
if (isset($row['tenant_auth_microsoft']) && is_array($row['tenant_auth_microsoft'])) {
return $row['tenant_auth_microsoft'];
}
return $row;
}
public static 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);
}
public static 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));
if (!$tenantIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
$rows = DB::select(
'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 in (' . $placeholders . ')',
...array_map('strval', $tenantIds)
);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$item = self::unwrap($row);
if (!is_array($item)) {
continue;
}
$tenantId = (int) ($item['tenant_id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
$result[$tenantId] = $item;
}
return $result;
}
public static 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()) ' .
'on duplicate key update enabled = values(enabled), enforce_microsoft_login = values(enforce_microsoft_login), sync_profile_on_login = values(sync_profile_on_login), sync_profile_fields = values(sync_profile_fields), entra_tenant_id = values(entra_tenant_id), ' .
'allowed_domains = values(allowed_domains), use_shared_app = values(use_shared_app), client_id_override = values(client_id_override), ' .
'client_secret_override_enc = values(client_secret_override_enc), modified = NOW()',
(string) $tenantId,
!empty($data['enabled']) ? '1' : '0',
!empty($data['enforce_microsoft_login']) ? '1' : '0',
!empty($data['sync_profile_on_login']) ? '1' : '0',
$data['sync_profile_fields'] ?? null,
$data['entra_tenant_id'] ?? null,
$data['allowed_domains'] ?? null,
!empty($data['use_shared_app']) ? '1' : '0',
$data['client_id_override'] ?? null,
$data['client_secret_override_enc'] ?? null
);
return $result !== false;
}
}

View File

@@ -33,7 +33,7 @@ class TenantRepository
public static 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, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants order by id desc'
'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);
}
@@ -54,6 +54,22 @@ class TenantRepository
return array_values(array_unique($ids));
}
public static 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));
if (!$tenantIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
$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 where id in (' . $placeholders . ')',
...array_map('strval', $tenantIds)
);
return self::unwrapList($rows);
}
public static function listActiveIdsByIds(array $tenantIds): array
{
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
@@ -85,7 +101,7 @@ class TenantRepository
public static function listPaged(array $options): array
{
$search = trim((string) ($options['search'] ?? ''));
$allowedOrder = ['id', 'uuid', 'description', 'created', 'modified'];
$allowedOrder = ['id', 'uuid', 'description', 'status', 'created', 'modified'];
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options);
[$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder);
@@ -99,12 +115,23 @@ class TenantRepository
$params[] = (string) $tenantUserId;
}
}
if (array_key_exists('tenantIds', $options)) {
$tenantIds = $options['tenantIds'];
$tenantIds = is_array($tenantIds) ? array_values(array_unique(array_map('intval', $tenantIds))) : [];
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
if ($tenantIds) {
$where[] = 'tenants.id in (???)';
$params[] = $tenantIds;
} else {
$where[] = '1=0';
}
}
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
$count = DB::selectValue('select count(*) from tenants' . $whereSql, ...$params);
$total = $count ? (int) $count : 0;
$query = '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, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants' .
$query = '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' .
$whereSql .
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
@@ -120,7 +147,7 @@ class TenantRepository
public static 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, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where id = ? limit 1',
'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);
@@ -129,25 +156,17 @@ class TenantRepository
public static 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, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where uuid = ? limit 1',
'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);
}
private static function uuidV4(): string
{
$data = random_bytes(16);
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
public static 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, status, status_changed_at, status_changed_by, created_by, created) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
$data['uuid'] ?? self::uuidV4(),
'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())',
$data['uuid'] ?? RepoQuery::uuidV4(),
$data['description'],
$data['address'] ?? null,
$data['postal_code'] ?? null,
@@ -166,6 +185,8 @@ class TenantRepository
$data['privacy_url'] ?? null,
$data['imprint_url'] ?? null,
$data['primary_color'] ?? null,
$data['default_theme'] ?? null,
$data['allow_user_theme'] ?? null,
$data['status'] ?? 'active',
$data['status_changed_at'] ?? null,
$data['status_changed_by'] ?? null,
@@ -194,6 +215,8 @@ class TenantRepository
'privacy_url' => $data['privacy_url'] ?? null,
'imprint_url' => $data['imprint_url'] ?? null,
'primary_color' => $data['primary_color'] ?? null,
'default_theme' => $data['default_theme'] ?? null,
'allow_user_theme' => $data['allow_user_theme'] ?? null,
'status' => $data['status'] ?? 'active',
];
if (array_key_exists('modified_by', $data)) {

View File

@@ -39,4 +39,64 @@ class UserTenantRepository
return true;
}
public static function countUsersByTenantIds(array $tenantIds): array
{
return self::countByTenantIds($tenantIds, false);
}
public static function countActiveUsersByTenantIds(array $tenantIds): array
{
return self::countByTenantIds($tenantIds, true);
}
private static 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));
if (!$tenantIds) {
return [];
}
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
$joinUsersSql = $activeOnly ? ' join users u on u.id = ut.user_id and u.active = 1' : '';
$rows = DB::select(
'select ut.tenant_id, count(distinct ut.user_id) as user_count from user_tenants ut' .
$joinUsersSql .
' where ut.tenant_id in (' . $placeholders . ') group by ut.tenant_id',
...array_map('strval', $tenantIds)
);
if (!is_array($rows)) {
return [];
}
$result = [];
foreach ($rows as $row) {
$tenantId = self::extractIntField($row, 'tenant_id');
if ($tenantId <= 0) {
continue;
}
$result[$tenantId] = self::extractIntField($row, 'user_count');
}
return $result;
}
private static function extractIntField(array $row, string $field): int
{
foreach ($row as $value) {
if (!is_array($value)) {
continue;
}
if (array_key_exists($field, $value)) {
return (int) $value[$field];
}
}
if (array_key_exists($field, $row)) {
return (int) $row[$field];
}
return 0;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace MintyPHP\Repository\User;
use MintyPHP\DB;
class UserExternalIdentityRepository
{
private static function unwrap($row): ?array
{
if (!$row || !is_array($row)) {
return null;
}
if (isset($row['user_external_identities']) && is_array($row['user_external_identities'])) {
return $row['user_external_identities'];
}
return $row;
}
public static 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',
$provider,
$tid,
$oid
);
return self::unwrap($row);
}
public static 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',
$provider,
$issuer,
$subject
);
return self::unwrap($row);
}
public static 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())',
(string) ((int) ($data['user_id'] ?? 0)),
(string) ($data['provider'] ?? ''),
(string) ($data['oid'] ?? ''),
(string) ($data['tid'] ?? ''),
(string) ($data['issuer'] ?? ''),
(string) ($data['subject'] ?? ''),
$data['email_at_link_time'] ?? null
);
}
}

View File

@@ -15,10 +15,16 @@ class UserRepository
$createdTo = trim((string) ($options['created_to'] ?? ''));
$tenant = trim((string) ($options['tenant'] ?? ''));
$tenantUuids = array_filter(array_map('trim', explode(',', (string) ($options['tenants'] ?? ''))));
$roleIds = self::normalizeIdList($options['roles'] ?? []);
$departmentIds = self::normalizeIdList($options['departments'] ?? []);
$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 = [];
@@ -34,11 +40,11 @@ class UserRepository
['aliases' => ['0', 'false', 'inactive'], 'sql' => 'users.active = ?', 'params' => ['0']],
]);
if ($createdFrom !== '') {
if ($createdFrom !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) {
$where[] = 'users.created >= ?';
$params[] = $createdFrom . ' 00:00:00';
}
if ($createdTo !== '') {
if ($createdTo !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
$where[] = 'users.created <= ?';
$params[] = $createdTo . ' 23:59:59';
}
@@ -69,6 +75,68 @@ class UserRepository
['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) {
@@ -93,7 +161,7 @@ class UserRepository
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.active, ' .
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 .
@@ -157,27 +225,48 @@ class UserRepository
return $list;
}
$scopeToActiveTenants = !empty($options['tenantUserId']);
$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;
}
$labelRows = DB::select(
'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 . ') order by t.description asc',
...array_map('strval', $ids)
);
$roleLabelRows = DB::select(
$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)
);
$departmentLabelRows = DB::select(
'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 . ') order by d.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) {
@@ -225,8 +314,8 @@ class UserRepository
foreach ($tenantMapByUser as $userId => $map) {
$tenantLabelsByUser[$userId] = array_values($map);
}
$roleLabelsByUser = self::collectLabels($roleLabelRows, 'ur', 'r');
$departmentLabelsByUser = self::collectLabels($departmentLabelRows, 'ud', 'd');
$roleLabelsByUser = self::collectLabels($roleRows, 'ur', 'r');
$departmentLabelsByUser = self::collectLabels($departmentRows, 'ud', 'd');
foreach ($list as &$user) {
$userId = (int) ($user['id'] ?? 0);
@@ -242,6 +331,7 @@ class UserRepository
return $list;
}
private static function unwrap(?array $row): ?array
{
if (!$row) {
@@ -277,25 +367,66 @@ class UserRepository
return $list;
}
public static function list(): array
{
$rows = DB::select(
'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, theme, primary_tenant_id, created_by, modified_by, created, modified, active from users order by id desc'
);
return self::unwrapList($rows);
}
public static function updateLastLogin(int $userId): void
public static function updateLastLogin(int $userId, string $provider = 'local'): void
{
if ($userId <= 0) {
return;
}
DB::query('update users set last_login_at = UTC_TIMESTAMP() where id = ?', (string) $userId);
$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', 'email', 'created', 'modified', 'active', 'last_login_at'];
$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);
@@ -320,7 +451,7 @@ class UserRepository
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, password, locale, totp_secret, theme, primary_tenant_id, created_by, modified_by, created, modified, active from users where id = ? limit 1',
'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);
@@ -329,7 +460,7 @@ class UserRepository
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, password, locale, totp_secret, theme, primary_tenant_id, created_by, modified_by, created, modified, active, active_changed_at, active_changed_by from users where uuid = ? limit 1',
'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);
@@ -338,7 +469,7 @@ class UserRepository
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, email_verified_at, password, locale, totp_secret, theme, primary_tenant_id, created_by, modified_by, created, modified, active, active_changed_at, active_changed_by from users where email = ? limit 1',
'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);
@@ -355,20 +486,12 @@ class UserRepository
return $name;
}
private static function uuidV4(): string
{
$data = random_bytes(16);
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
public static function create(array $data)
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'] ?? self::uuidV4(),
$data['uuid'] ?? RepoQuery::uuidV4(),
$data['first_name'],
$data['last_name'],
self::buildDisplayName($data),
@@ -491,6 +614,118 @@ class UserRepository
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);
@@ -503,6 +738,62 @@ class UserRepository
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);
@@ -534,23 +825,17 @@ class UserRepository
return $result !== false;
}
private static function normalizeIdList($value): array
private static function extractIdList(array $rows): array
{
if (is_string($value)) {
$value = array_filter(array_map('trim', explode(',', $value)));
} elseif (!is_array($value)) {
return [];
}
$ids = [];
foreach ($value as $item) {
if ($item === '' || $item === null) {
continue;
foreach ($rows as $row) {
$data = $row['users'] ?? $row;
$id = (int) ($data['id'] ?? $row['id'] ?? 0);
if ($id > 0) {
$ids[] = $id;
}
$ids[] = (int) $item;
}
$ids = array_values(array_filter(array_unique($ids), static function ($id) {
return $id > 0;
}));
return $ids;
return array_values(array_unique($ids));
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace MintyPHP\Repository\User;
use MintyPHP\DB;
use MintyPHP\Repository\Support\RepoQuery;
class UserSavedFilterRepository
{
private static function unwrapList($rows): array
{
if (!is_array($rows)) {
return [];
}
$list = [];
foreach ($rows as $row) {
$data = $row['user_saved_filters'] ?? $row;
if (is_array($data)) {
$list[] = $data;
}
}
return $list;
}
public static function listByUserAndContext(int $userId, string $context): array
{
if ($userId <= 0 || $context === '') {
return [];
}
$rows = DB::select(
'select id, uuid, user_id, context, name, query_json, created, modified from user_saved_filters where user_id = ? and context = ? order by created desc, id desc',
(string) $userId,
$context
);
return self::unwrapList($rows);
}
public static function countByUserAndContext(int $userId, string $context): int
{
if ($userId <= 0 || $context === '') {
return 0;
}
$count = DB::selectValue(
'select count(*) from user_saved_filters where user_id = ? and context = ?',
(string) $userId,
$context
);
return $count ? (int) $count : 0;
}
public static function create(array $data): int|false
{
$userId = (int) ($data['user_id'] ?? 0);
$context = trim((string) ($data['context'] ?? ''));
$name = trim((string) ($data['name'] ?? ''));
$queryJson = (string) ($data['query_json'] ?? '');
if ($userId <= 0 || $context === '' || $name === '' || $queryJson === '') {
return false;
}
return DB::insert(
'insert into user_saved_filters (uuid, user_id, context, name, query_json, created) values (?,?,?,?,?,NOW())',
$data['uuid'] ?? RepoQuery::uuidV4(),
(string) $userId,
$context,
$name,
$queryJson
);
}
public static function deleteByUuidForUserAndContext(string $uuid, int $userId, string $context): bool
{
$uuid = trim($uuid);
$context = trim($context);
if ($uuid === '' || $userId <= 0 || $context === '') {
return false;
}
$deleted = DB::delete(
'delete from user_saved_filters where uuid = ? and user_id = ? and context = ?',
$uuid,
(string) $userId,
$context
);
return $deleted !== false && (int) $deleted > 0;
}
}