refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root immediately communicates which code is core platform and which lives in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the Composer PSR-4 path mapping moves from lib/ to core/. Module-internal lib/ directories (modules/*/lib/) are untouched. Updated across the full stack: - composer.json PSR-4 mapping - bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php) - tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer) - 26 architecture contract tests - enforcement-policy, guard-catalog, quality-gates - all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/) - bin/qa-extended.sh search paths - .claude/settings.local.json permission paths Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner → Executor → Code Review (4 findings fixed) → Security Review → Acceptance Test → Finalizer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
127
core/Repository/Access/PermissionRepository.php
Normal file
127
core/Repository/Access/PermissionRepository.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
use MintyPHP\Repository\Support\RepositoryArrayHelper;
|
||||
|
||||
/** Reads and writes permission records; supports active/system flag filtering. */
|
||||
class PermissionRepository implements PermissionRepositoryInterface
|
||||
{
|
||||
public function list(): array
|
||||
{
|
||||
$rows = DB::select('select id, `key`, description, active, is_system, created from permissions order by `key` asc');
|
||||
|
||||
return RepositoryArrayHelper::unwrapList($rows, 'permissions');
|
||||
}
|
||||
|
||||
public function listActive(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, `key`, description, active, is_system, created from permissions where active = 1 order by `key` asc'
|
||||
);
|
||||
|
||||
return RepositoryArrayHelper::unwrapList($rows, 'permissions');
|
||||
}
|
||||
|
||||
public function listPaged(array $options): array
|
||||
{
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$allowedOrder = [
|
||||
'key' => '`key`',
|
||||
'description' => 'description',
|
||||
'active' => 'active',
|
||||
'created' => 'created',
|
||||
];
|
||||
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options);
|
||||
[$order, $dir] = RepoQuery::sanitizeOrder($options, array_keys($allowedOrder), 'key', 'asc');
|
||||
$orderBy = $allowedOrder[$order] ?? $allowedOrder['key'];
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
RepoQuery::addLikeFilter($where, $params, ['permissions.`key`', 'permissions.description'], $search);
|
||||
$activeValue = array_key_exists('active', $options) ? $options['active'] : null;
|
||||
RepoQuery::addEnumFilter($where, $params, $activeValue, [
|
||||
['aliases' => ['1', 'true', 'active'], 'sql' => 'permissions.active = 1'],
|
||||
['aliases' => ['0', 'false', 'inactive'], 'sql' => 'permissions.active = 0'],
|
||||
]);
|
||||
$whereSql = $where ? ' where ' . implode(' and ', $where) : '';
|
||||
|
||||
$total = DB::selectValue('select count(*) from permissions' . $whereSql, ...$params);
|
||||
$query = 'select id, `key`, description, active, is_system, created from permissions' . $whereSql .
|
||||
' order by ' . $orderBy . ' ' . $dir . ' limit ? offset ?';
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = DB::select($query, ...$queryParams);
|
||||
|
||||
return ['rows' => RepositoryArrayHelper::unwrapList($rows, 'permissions'), 'total' => (int) $total];
|
||||
}
|
||||
|
||||
public function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, `key`, description, active, is_system, created from permissions where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
|
||||
return RepositoryArrayHelper::unwrap($row, 'permissions');
|
||||
}
|
||||
|
||||
public function findByKey(string $key): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, `key`, description, active, is_system, created from permissions where `key` = ? limit 1',
|
||||
$key
|
||||
);
|
||||
|
||||
return RepositoryArrayHelper::unwrap($row, 'permissions');
|
||||
}
|
||||
|
||||
public function create(array $data): ?int
|
||||
{
|
||||
$key = trim((string) ($data['key'] ?? ''));
|
||||
$desc = trim((string) ($data['description'] ?? ''));
|
||||
$active = (int) ($data['active'] ?? 1);
|
||||
$isSystem = (int) ($data['is_system'] ?? 0);
|
||||
$result = DB::insert(
|
||||
'insert into permissions (`key`, description, active, is_system, created) values (?,?,?,?,NOW())',
|
||||
$key,
|
||||
$desc !== '' ? $desc : null,
|
||||
(string) $active,
|
||||
(string) $isSystem
|
||||
);
|
||||
return $result ? (int) $result : null;
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): bool
|
||||
{
|
||||
$key = trim((string) ($data['key'] ?? ''));
|
||||
$desc = trim((string) ($data['description'] ?? ''));
|
||||
$active = (int) ($data['active'] ?? 1);
|
||||
$isSystem = (int) ($data['is_system'] ?? 0);
|
||||
$result = DB::update(
|
||||
'update permissions set `key` = ?, description = ?, active = ?, is_system = ? where id = ?',
|
||||
$key,
|
||||
$desc !== '' ? $desc : null,
|
||||
(string) $active,
|
||||
(string) $isSystem,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from permissions where id = ?', (string) $id);
|
||||
return (bool) $result;
|
||||
}
|
||||
|
||||
public function listSystem(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, `key`, description, active, is_system, created from permissions where is_system = 1 order by `key` asc'
|
||||
);
|
||||
|
||||
return RepositoryArrayHelper::unwrapList($rows, 'permissions');
|
||||
}
|
||||
}
|
||||
26
core/Repository/Access/PermissionRepositoryInterface.php
Normal file
26
core/Repository/Access/PermissionRepositoryInterface.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
/** Contract for permission CRUD and lookup by key or ID. */
|
||||
interface PermissionRepositoryInterface
|
||||
{
|
||||
public function list(): array;
|
||||
|
||||
public function listActive(): array;
|
||||
|
||||
public function listPaged(array $options): array;
|
||||
|
||||
public function find(int $id): ?array;
|
||||
|
||||
public function findByKey(string $key): ?array;
|
||||
|
||||
public function create(array $data): ?int;
|
||||
|
||||
public function update(int $id, array $data): bool;
|
||||
|
||||
public function delete(int $id): bool;
|
||||
|
||||
/** @return list<array{id: int, key: string, description: string, active: int, is_system: int}> */
|
||||
public function listSystem(): array;
|
||||
}
|
||||
75
core/Repository/Access/RoleAssignableRoleRepository.php
Normal file
75
core/Repository/Access/RoleAssignableRoleRepository.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Manages which roles a given role is allowed to assign (role hierarchy). */
|
||||
class RoleAssignableRoleRepository implements RoleAssignableRoleRepositoryInterface
|
||||
{
|
||||
public function listAssignableRoleIdsByRoleId(int $roleId): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select assignable_role_id from role_assignable_roles where role_id = ?',
|
||||
(string) $roleId
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['role_assignable_roles'] ?? $row;
|
||||
if (is_array($data) && isset($data['assignable_role_id'])) {
|
||||
$ids[] = (int) $data['assignable_role_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public function listAssignableRoleIdsByRoleIds(array $roleIds): array
|
||||
{
|
||||
$roleIds = array_values(array_unique(array_map('intval', $roleIds)));
|
||||
$roleIds = array_values(array_filter($roleIds, static fn (int $id): bool => $id > 0));
|
||||
if (!$roleIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($roleIds), '?'));
|
||||
$rows = DB::select(
|
||||
'select distinct assignable_role_id from role_assignable_roles where role_id in (' .
|
||||
$placeholders .
|
||||
')',
|
||||
...array_map('strval', $roleIds)
|
||||
);
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['role_assignable_roles'] ?? $row;
|
||||
if (is_array($data) && isset($data['assignable_role_id'])) {
|
||||
$ids[] = (int) $data['assignable_role_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public function replaceForRole(int $roleId, array $assignableRoleIds): bool
|
||||
{
|
||||
DB::delete('delete from role_assignable_roles where role_id = ?', (string) $roleId);
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $assignableRoleIds))));
|
||||
if (!$ids) {
|
||||
return true;
|
||||
}
|
||||
foreach ($ids as $assignableRoleId) {
|
||||
DB::insert(
|
||||
'insert into role_assignable_roles (role_id, assignable_role_id, created) values (?,?,NOW())',
|
||||
(string) $roleId,
|
||||
(string) $assignableRoleId
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
interface RoleAssignableRoleRepositoryInterface
|
||||
{
|
||||
/** @return int[] */
|
||||
public function listAssignableRoleIdsByRoleId(int $roleId): array;
|
||||
|
||||
/**
|
||||
* Union of assignable role IDs across multiple roles (e.g. all roles of an actor).
|
||||
* @param int[] $roleIds
|
||||
* @return int[]
|
||||
*/
|
||||
public function listAssignableRoleIdsByRoleIds(array $roleIds): array;
|
||||
|
||||
/**
|
||||
* Atomic replace: delete all + re-insert.
|
||||
* @param int[] $assignableRoleIds
|
||||
*/
|
||||
public function replaceForRole(int $roleId, array $assignableRoleIds): bool;
|
||||
}
|
||||
198
core/Repository/Access/RolePermissionRepository.php
Normal file
198
core/Repository/Access/RolePermissionRepository.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Manages the many-to-many link between roles and permissions. */
|
||||
class RolePermissionRepository implements RolePermissionRepositoryInterface
|
||||
{
|
||||
public function listPermissionIdsByRoleId(int $roleId): array
|
||||
{
|
||||
$rows = DB::select('select permission_id from role_permissions where role_id = ?', (string) $roleId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['role_permissions'] ?? $row;
|
||||
if (is_array($data) && isset($data['permission_id'])) {
|
||||
$ids[] = (int) $data['permission_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public function countPermissionsByRoleIds(array $roleIds): array
|
||||
{
|
||||
$roleIds = array_values(array_unique(array_map('intval', $roleIds)));
|
||||
$roleIds = array_values(array_filter($roleIds, static fn ($id) => $id > 0));
|
||||
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 function replaceForRole(int $roleId, array $permissionIds): bool
|
||||
{
|
||||
DB::delete('delete from role_permissions where role_id = ?', (string) $roleId);
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $permissionIds))));
|
||||
if (!$ids) {
|
||||
return true;
|
||||
}
|
||||
foreach ($ids as $permissionId) {
|
||||
DB::insert(
|
||||
'insert into role_permissions (role_id, permission_id, created) values (?,?,NOW())',
|
||||
(string) $roleId,
|
||||
(string) $permissionId
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function listRoleIdsByPermissionId(int $permissionId): array
|
||||
{
|
||||
$rows = DB::select('select role_id from role_permissions where permission_id = ?', (string) $permissionId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['role_permissions'] ?? $row;
|
||||
if (is_array($data) && isset($data['role_id'])) {
|
||||
$ids[] = (int) $data['role_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public function replaceForPermission(int $permissionId, array $roleIds): bool
|
||||
{
|
||||
DB::delete('delete from role_permissions where permission_id = ?', (string) $permissionId);
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $roleIds))));
|
||||
if (!$ids) {
|
||||
return true;
|
||||
}
|
||||
foreach ($ids as $roleId) {
|
||||
DB::insert(
|
||||
'insert into role_permissions (role_id, permission_id, created) values (?,?,NOW())',
|
||||
(string) $roleId,
|
||||
(string) $permissionId
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function listPermissionKeysByRoleIds(array $roleIds): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $roleIds))));
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select p.`key` as `key` from role_permissions rp ' .
|
||||
'join roles r on r.id = rp.role_id and r.active = 1 ' .
|
||||
'join permissions p on p.id = rp.permission_id and p.active = 1 ' .
|
||||
'where rp.role_id in (???)',
|
||||
array_map('strval', $ids)
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$keys = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['p'] ?? $row;
|
||||
if (is_array($data) && isset($data['key'])) {
|
||||
$keys[] = (string) $data['key'];
|
||||
} elseif (is_array($row) && isset($row['key'])) {
|
||||
$keys[] = (string) $row['key'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
public function listPermissionsWithRolesByRoleIds(array $roleIds): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $roleIds))));
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select rp.permission_id as permission_id, p.`key` as `key`, p.description as `description`, r.description as role ' .
|
||||
'from role_permissions rp join permissions p on p.id = rp.permission_id and p.active = 1 ' .
|
||||
'join roles r on r.id = rp.role_id and r.active = 1 where rp.role_id in (???) ' .
|
||||
'order by p.`key` asc, r.description asc',
|
||||
array_map('strval', $ids)
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$permMap = [];
|
||||
foreach ($rows as $row) {
|
||||
$rowPerm = $row['p'] ?? ($row['permissions'] ?? $row);
|
||||
$rowRole = $row['r'] ?? ($row['roles'] ?? $row);
|
||||
$rowRp = $row['rp'] ?? ($row['role_permissions'] ?? $row);
|
||||
|
||||
$permId = $rowRp['permission_id'] ?? ($row['permission_id'] ?? null);
|
||||
$key = (string) ($rowPerm['key'] ?? $row['key'] ?? '');
|
||||
if ($permId === null || $key === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($permMap[$permId])) {
|
||||
$desc = (string) ($rowPerm['description'] ?? $row['description'] ?? '');
|
||||
$permMap[$permId] = [
|
||||
'key' => $key,
|
||||
'description' => $desc,
|
||||
'roles' => [],
|
||||
];
|
||||
}
|
||||
$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']));
|
||||
}
|
||||
unset($item);
|
||||
return array_values($permMap);
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
}
|
||||
21
core/Repository/Access/RolePermissionRepositoryInterface.php
Normal file
21
core/Repository/Access/RolePermissionRepositoryInterface.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
/** Contract for managing permission assignments on roles. */
|
||||
interface RolePermissionRepositoryInterface
|
||||
{
|
||||
public function listPermissionIdsByRoleId(int $roleId): array;
|
||||
|
||||
public function countPermissionsByRoleIds(array $roleIds): array;
|
||||
|
||||
public function replaceForRole(int $roleId, array $permissionIds): bool;
|
||||
|
||||
public function listRoleIdsByPermissionId(int $permissionId): array;
|
||||
|
||||
public function replaceForPermission(int $permissionId, array $roleIds): bool;
|
||||
|
||||
public function listPermissionKeysByRoleIds(array $roleIds): array;
|
||||
|
||||
public function listPermissionsWithRolesByRoleIds(array $roleIds): array;
|
||||
}
|
||||
163
core/Repository/Access/RoleRepository.php
Normal file
163
core/Repository/Access/RoleRepository.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
use MintyPHP\Repository\Support\RepositoryArrayHelper;
|
||||
|
||||
/** Reads and writes role records with pagination, search, and code uniqueness checks. */
|
||||
class RoleRepository implements RoleRepositoryInterface
|
||||
{
|
||||
public function list(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles order by id desc'
|
||||
);
|
||||
return RepositoryArrayHelper::unwrapList($rows, 'roles');
|
||||
}
|
||||
|
||||
public function listActive(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles where active = 1 order by description asc'
|
||||
);
|
||||
return RepositoryArrayHelper::unwrapList($rows, 'roles');
|
||||
}
|
||||
|
||||
public function listByIds(array $roleIds): array
|
||||
{
|
||||
$roleIds = RepositoryArrayHelper::sanitizePositiveIds($roleIds);
|
||||
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 RepositoryArrayHelper::unwrapList($rows, 'roles');
|
||||
}
|
||||
|
||||
public function listIds(): array
|
||||
{
|
||||
$rows = DB::select('select id from roles');
|
||||
|
||||
return RepositoryArrayHelper::extractIds($rows, 'roles');
|
||||
}
|
||||
|
||||
public function listActiveIds(): array
|
||||
{
|
||||
$rows = DB::select('select id from roles where active = 1');
|
||||
|
||||
return RepositoryArrayHelper::extractIds($rows, 'roles');
|
||||
}
|
||||
|
||||
public function listPaged(array $options): array
|
||||
{
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$allowedOrder = ['id', 'uuid', 'description', 'code', 'active', 'created', 'modified'];
|
||||
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options);
|
||||
[$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder);
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
RepoQuery::addLikeFilter($where, $params, ['description', 'uuid', 'code'], $search);
|
||||
$activeValue = array_key_exists('active', $options) ? $options['active'] : null;
|
||||
RepoQuery::addEnumFilter($where, $params, $activeValue, [
|
||||
['aliases' => ['1', 'true', 'active'], 'sql' => 'roles.active = 1'],
|
||||
['aliases' => ['0', 'false', 'inactive'], 'sql' => 'roles.active = 0'],
|
||||
]);
|
||||
|
||||
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||
$count = DB::selectValue('select count(*) from roles' . $whereSql, ...$params);
|
||||
$total = $count ? (int) $count : 0;
|
||||
|
||||
$query = 'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles' .
|
||||
$whereSql .
|
||||
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
|
||||
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => RepositoryArrayHelper::unwrapList($rows, 'roles'),
|
||||
];
|
||||
}
|
||||
|
||||
public function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return RepositoryArrayHelper::unwrap($row, 'roles');
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, code, active, created_by, modified_by, created, modified from roles where uuid = ? limit 1',
|
||||
$uuid
|
||||
);
|
||||
return RepositoryArrayHelper::unwrap($row, 'roles');
|
||||
}
|
||||
|
||||
public function existsByCode(string $code, int $excludeId = 0): bool
|
||||
{
|
||||
$code = trim($code);
|
||||
if ($code === '') {
|
||||
return false;
|
||||
}
|
||||
if ($excludeId > 0) {
|
||||
$count = DB::selectValue('select count(*) from roles where code = ? and id != ?', $code, (string) $excludeId);
|
||||
} else {
|
||||
$count = DB::selectValue('select count(*) from roles where code = ?', $code);
|
||||
}
|
||||
return $count ? ((int) $count > 0) : false;
|
||||
}
|
||||
|
||||
public function create(array $data): int|false
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into roles (uuid, description, code, active, created_by, created) values (?,?,?,?,?,NOW())',
|
||||
$data['uuid'] ?? RepoQuery::uuidV4(),
|
||||
$data['description'],
|
||||
$data['code'] ?? null,
|
||||
$data['active'] ?? 1,
|
||||
$data['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'description' => $data['description'],
|
||||
'code' => $data['code'] ?? null,
|
||||
'active' => $data['active'] ?? 1,
|
||||
];
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update roles set ' . implode(', ', $setParts) . ' where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from roles where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
31
core/Repository/Access/RoleRepositoryInterface.php
Normal file
31
core/Repository/Access/RoleRepositoryInterface.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
/** Contract for role CRUD, existence checks, and filtered listing. */
|
||||
interface RoleRepositoryInterface
|
||||
{
|
||||
public function list(): array;
|
||||
|
||||
public function listActive(): array;
|
||||
|
||||
public function listByIds(array $roleIds): array;
|
||||
|
||||
public function listIds(): array;
|
||||
|
||||
public function listActiveIds(): array;
|
||||
|
||||
public function listPaged(array $options): array;
|
||||
|
||||
public function find(int $id): ?array;
|
||||
|
||||
public function findByUuid(string $uuid): ?array;
|
||||
|
||||
public function existsByCode(string $code, int $excludeId = 0): bool;
|
||||
|
||||
public function create(array $data): int|false;
|
||||
|
||||
public function update(int $id, array $data): bool;
|
||||
|
||||
public function delete(int $id): bool;
|
||||
}
|
||||
110
core/Repository/Access/UserRoleRepository.php
Normal file
110
core/Repository/Access/UserRoleRepository.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepositoryArrayHelper;
|
||||
|
||||
/** Queries user-role assignments and counts active/inactive users per role. */
|
||||
class UserRoleRepository implements UserRoleRepositoryInterface
|
||||
{
|
||||
public function listRoleIdsByUserId(int $userId): array
|
||||
{
|
||||
$rows = DB::select('select role_id from user_roles where user_id = ?', (string) $userId);
|
||||
|
||||
return RepositoryArrayHelper::extractIds($rows, 'user_roles', 'role_id');
|
||||
}
|
||||
|
||||
public function replaceForUser(int $userId, array $roleIds): bool
|
||||
{
|
||||
DB::delete('delete from user_roles where user_id = ?', (string) $userId);
|
||||
if (!$roleIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($roleIds as $roleId) {
|
||||
DB::insert(
|
||||
'insert into user_roles (user_id, role_id, created) values (?,?,NOW())',
|
||||
(string) $userId,
|
||||
(string) $roleId
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function listUserIdsByRoleIds(array $roleIds): array
|
||||
{
|
||||
$ids = RepositoryArrayHelper::sanitizePositiveIds($roleIds);
|
||||
if (!$ids) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::select(
|
||||
'select distinct user_id from user_roles where role_id in (???)',
|
||||
array_map('strval', $ids)
|
||||
);
|
||||
|
||||
return RepositoryArrayHelper::sanitizePositiveIds(
|
||||
RepositoryArrayHelper::extractIds($rows, 'user_roles', 'user_id')
|
||||
);
|
||||
}
|
||||
|
||||
public function countUsersByRoleIds(array $roleIds): array
|
||||
{
|
||||
return $this->countByRoleIds($roleIds, false);
|
||||
}
|
||||
|
||||
public function countActiveUsersByRoleIds(array $roleIds): array
|
||||
{
|
||||
return $this->countByRoleIds($roleIds, true);
|
||||
}
|
||||
|
||||
private function countByRoleIds(array $roleIds, bool $activeOnly): array
|
||||
{
|
||||
$roleIds = RepositoryArrayHelper::sanitizePositiveIds($roleIds);
|
||||
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 = $this->extractIntField($row, 'role_id');
|
||||
if ($roleId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$result[$roleId] = $this->extractIntField($row, 'user_count');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
}
|
||||
17
core/Repository/Access/UserRoleRepositoryInterface.php
Normal file
17
core/Repository/Access/UserRoleRepositoryInterface.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Access;
|
||||
|
||||
/** Contract for querying user-role relationships and role usage counts. */
|
||||
interface UserRoleRepositoryInterface
|
||||
{
|
||||
public function listRoleIdsByUserId(int $userId): array;
|
||||
|
||||
public function replaceForUser(int $userId, array $roleIds): bool;
|
||||
|
||||
public function listUserIdsByRoleIds(array $roleIds): array;
|
||||
|
||||
public function countUsersByRoleIds(array $roleIds): array;
|
||||
|
||||
public function countActiveUsersByRoleIds(array $roleIds): array;
|
||||
}
|
||||
223
core/Repository/Auth/ApiTokenRepository.php
Normal file
223
core/Repository/Auth/ApiTokenRepository.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
/** Persists API tokens with selector/hash pairs, expiry tracking, and usage timestamps. */
|
||||
class ApiTokenRepository implements ApiTokenRepositoryInterface
|
||||
{
|
||||
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 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 function isUuid(string $value): bool
|
||||
{
|
||||
return (bool) preg_match(self::UUID_REGEX, trim($value));
|
||||
}
|
||||
|
||||
public 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 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 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 function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if (!$this->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 function findByUuidForUser(string $uuid, int $userId): ?array
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($userId <= 0 || !$this->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 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 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 function revokeByUuidForUser(string $uuid, int $userId): bool
|
||||
{
|
||||
$uuid = trim($uuid);
|
||||
if ($userId <= 0 || !$this->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 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 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 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 $this->unwrapList($rows);
|
||||
}
|
||||
|
||||
public function countActiveForUser(int $userId): int
|
||||
{
|
||||
$count = DB::selectValue(
|
||||
'select count(*) from user_api_tokens where user_id = ? and revoked_at is null and (expires_at is null or expires_at > NOW())',
|
||||
(string) $userId
|
||||
);
|
||||
return $count ? (int) $count : 0;
|
||||
}
|
||||
|
||||
public 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 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;
|
||||
}
|
||||
|
||||
}
|
||||
45
core/Repository/Auth/ApiTokenRepositoryInterface.php
Normal file
45
core/Repository/Auth/ApiTokenRepositoryInterface.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
/** Contract for API bearer token creation, lookup by selector or UUID, and revocation. */
|
||||
interface ApiTokenRepositoryInterface
|
||||
{
|
||||
public function isUuid(string $value): bool;
|
||||
|
||||
public function create(
|
||||
int $userId,
|
||||
string $name,
|
||||
string $selector,
|
||||
string $tokenHash,
|
||||
?int $tenantId,
|
||||
?string $expiresAt,
|
||||
?int $createdBy
|
||||
): ?int;
|
||||
|
||||
public function findBySelector(string $selector): ?array;
|
||||
|
||||
public function find(int $id): ?array;
|
||||
|
||||
public function findByUuid(string $uuid): ?array;
|
||||
|
||||
public function findByUuidForUser(string $uuid, int $userId): ?array;
|
||||
|
||||
public function updateLastUsed(int $id, string $ip): bool;
|
||||
|
||||
public function revoke(int $id): bool;
|
||||
|
||||
public function revokeByUuidForUser(string $uuid, int $userId): bool;
|
||||
|
||||
public function revokeAllForUser(int $userId, ?int $tenantId = null): int;
|
||||
|
||||
public function revokeAllActiveByAdmin(): int;
|
||||
|
||||
public function listByUserId(int $userId, int $limit = 25): array;
|
||||
|
||||
public function countActiveForUser(int $userId): int;
|
||||
|
||||
public function countActiveForUserExcludingId(int $userId, int $excludeId): int;
|
||||
|
||||
public function countActive(): int;
|
||||
}
|
||||
72
core/Repository/Auth/EmailVerificationRepository.php
Normal file
72
core/Repository/Auth/EmailVerificationRepository.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Stores email verification codes with attempt counters, expiry, and completion status. */
|
||||
class EmailVerificationRepository implements EmailVerificationRepositoryInterface
|
||||
{
|
||||
public function create(int $userId, string $codeHash, string $expiresAt): ?int
|
||||
{
|
||||
$id = DB::insert(
|
||||
'insert into email_verifications (user_id, code_hash, expires_at, attempts, used_at, created) values (?,?,?,?,NULL,NOW())',
|
||||
(string) $userId,
|
||||
$codeHash,
|
||||
$expiresAt,
|
||||
0
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public function invalidateForUser(int $userId): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update email_verifications set used_at = NOW() where user_id = ? and used_at is null',
|
||||
(string) $userId
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function findActiveByUserId(int $userId): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from email_verifications where user_id = ? and used_at is null and expires_at > UTC_TIMESTAMP() order by id desc limit 1',
|
||||
(string) $userId
|
||||
);
|
||||
if (!$row || !isset($row['email_verifications'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['email_verifications'];
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from email_verifications where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
if (!$row || !isset($row['email_verifications'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['email_verifications'];
|
||||
}
|
||||
|
||||
public function incrementAttempts(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update email_verifications set attempts = attempts + 1 where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function markUsed(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update email_verifications set used_at = NOW() where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
/** Contract for email verification code lifecycle: creation, lookup, attempt tracking, and completion. */
|
||||
interface EmailVerificationRepositoryInterface
|
||||
{
|
||||
public function create(int $userId, string $codeHash, string $expiresAt): ?int;
|
||||
|
||||
public function invalidateForUser(int $userId): bool;
|
||||
|
||||
public function findActiveByUserId(int $userId): ?array;
|
||||
|
||||
public function findById(int $id): ?array;
|
||||
|
||||
public function incrementAttempts(int $id): bool;
|
||||
|
||||
public function markUsed(int $id): bool;
|
||||
}
|
||||
105
core/Repository/Auth/PasswordResetRepository.php
Normal file
105
core/Repository/Auth/PasswordResetRepository.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Stores password reset codes with attempt counters, expiry, and completion history. */
|
||||
class PasswordResetRepository implements PasswordResetRepositoryInterface
|
||||
{
|
||||
private function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['password_resets'] ?? $row;
|
||||
if (is_array($data)) {
|
||||
$list[] = $data;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function create(int $userId, string $codeHash, string $expiresAt): ?int
|
||||
{
|
||||
$id = DB::insert(
|
||||
'insert into password_resets (user_id, code_hash, expires_at, attempts, used_at, created) values (?,?,?,?,NULL,NOW())',
|
||||
(string) $userId,
|
||||
$codeHash,
|
||||
$expiresAt,
|
||||
0
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public function invalidateForUser(int $userId): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update password_resets set used_at = NOW() where user_id = ? and used_at is null',
|
||||
(string) $userId
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function findActiveByUserId(int $userId): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from password_resets where user_id = ? and used_at is null and expires_at > UTC_TIMESTAMP() order by id desc limit 1',
|
||||
(string) $userId
|
||||
);
|
||||
if (!$row || !isset($row['password_resets'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['password_resets'];
|
||||
}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, code_hash, expires_at, attempts, used_at from password_resets where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
if (!$row || !isset($row['password_resets'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['password_resets'];
|
||||
}
|
||||
|
||||
public function incrementAttempts(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update password_resets set attempts = attempts + 1 where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function markUsed(int $id): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update password_resets set used_at = NOW() where id = ?',
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function listByUserId(int $userId, int $limit = 20): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
if ($limit < 1) {
|
||||
$limit = 20;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select id, user_id, expires_at, attempts, used_at, created from password_resets where user_id = ? order by id desc limit ?',
|
||||
(string) $userId,
|
||||
(string) $limit
|
||||
);
|
||||
return $this->unwrapList($rows);
|
||||
}
|
||||
}
|
||||
21
core/Repository/Auth/PasswordResetRepositoryInterface.php
Normal file
21
core/Repository/Auth/PasswordResetRepositoryInterface.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
/** Contract for password reset code lifecycle: creation, lookup, attempt tracking, and completion. */
|
||||
interface PasswordResetRepositoryInterface
|
||||
{
|
||||
public function create(int $userId, string $codeHash, string $expiresAt): ?int;
|
||||
|
||||
public function invalidateForUser(int $userId): bool;
|
||||
|
||||
public function findActiveByUserId(int $userId): ?array;
|
||||
|
||||
public function findById(int $id): ?array;
|
||||
|
||||
public function incrementAttempts(int $id): bool;
|
||||
|
||||
public function markUsed(int $id): bool;
|
||||
|
||||
public function listByUserId(int $userId, int $limit = 20): array;
|
||||
}
|
||||
103
core/Repository/Auth/RememberTokenRepository.php
Normal file
103
core/Repository/Auth/RememberTokenRepository.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Manages persistent login tokens with rotation, family tracking, and admin-initiated expiry. */
|
||||
class RememberTokenRepository implements RememberTokenRepositoryInterface
|
||||
{
|
||||
private function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_remember_tokens'] ?? $row;
|
||||
if (is_array($data)) {
|
||||
$list[] = $data;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function create(int $userId, string $selector, string $tokenHash, string $expiresAt): ?int
|
||||
{
|
||||
$id = DB::insert(
|
||||
'insert into user_remember_tokens (user_id, selector, token_hash, expires_at, created) values (?,?,?,?,NOW())',
|
||||
(string) $userId,
|
||||
$selector,
|
||||
$tokenHash,
|
||||
$expiresAt
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public function findBySelector(string $selector): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, selector, token_hash, expires_at, expired_by_admin_at, last_used from user_remember_tokens where selector = ? limit 1',
|
||||
$selector
|
||||
);
|
||||
if (!$row || !isset($row['user_remember_tokens'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['user_remember_tokens'];
|
||||
}
|
||||
|
||||
public function updateToken(int $id, string $tokenHash, string $expiresAt): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update user_remember_tokens set token_hash = ?, expires_at = ?, expired_by_admin_at = NULL, last_used = NOW() where id = ?',
|
||||
$tokenHash,
|
||||
$expiresAt,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function expireAllByAdmin(): int
|
||||
{
|
||||
$result = DB::update(
|
||||
'update user_remember_tokens set expires_at = NOW(), expired_by_admin_at = NOW() where expires_at > NOW()'
|
||||
);
|
||||
return $result !== false ? (int) $result : 0;
|
||||
}
|
||||
|
||||
public function deleteById(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from user_remember_tokens where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function deleteByUserId(int $userId): bool
|
||||
{
|
||||
$result = DB::delete('delete from user_remember_tokens where user_id = ?', (string) $userId);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function listByUserId(int $userId, int $limit = 20): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return [];
|
||||
}
|
||||
if ($limit < 1) {
|
||||
$limit = 20;
|
||||
} elseif ($limit > 100) {
|
||||
$limit = 100;
|
||||
}
|
||||
$rows = DB::select(
|
||||
'select id, user_id, selector, expires_at, expired_by_admin_at, last_used, created from user_remember_tokens where user_id = ? order by id desc limit ?',
|
||||
(string) $userId,
|
||||
(string) $limit
|
||||
);
|
||||
return $this->unwrapList($rows);
|
||||
}
|
||||
|
||||
public function countActive(): int
|
||||
{
|
||||
$count = DB::selectValue('select count(*) from user_remember_tokens where expires_at > NOW()');
|
||||
return $count ? (int) $count : 0;
|
||||
}
|
||||
}
|
||||
23
core/Repository/Auth/RememberTokenRepositoryInterface.php
Normal file
23
core/Repository/Auth/RememberTokenRepositoryInterface.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Auth;
|
||||
|
||||
/** Contract for remember-me token creation, rotation, expiry, and active session counting. */
|
||||
interface RememberTokenRepositoryInterface
|
||||
{
|
||||
public function create(int $userId, string $selector, string $tokenHash, string $expiresAt): ?int;
|
||||
|
||||
public function findBySelector(string $selector): ?array;
|
||||
|
||||
public function updateToken(int $id, string $tokenHash, string $expiresAt): bool;
|
||||
|
||||
public function expireAllByAdmin(): int;
|
||||
|
||||
public function deleteById(int $id): bool;
|
||||
|
||||
public function deleteByUserId(int $userId): bool;
|
||||
|
||||
public function listByUserId(int $userId, int $limit = 20): array;
|
||||
|
||||
public function countActive(): int;
|
||||
}
|
||||
60
core/Repository/Content/PageContentRepository.php
Normal file
60
core/Repository/Content/PageContentRepository.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Content;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Reads and writes locale-specific content blocks for CMS pages. */
|
||||
class PageContentRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['page_contents'] ?? $row;
|
||||
}
|
||||
|
||||
public static function findByPageIdAndLocale(int $pageId, string $locale): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, page_id, locale, content, created_by, modified_by, created, modified from page_contents where page_id = ? and locale = ? limit 1',
|
||||
(string) $pageId,
|
||||
$locale
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
|
||||
public static function create(array $data)
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into page_contents (page_id, locale, content, created_by, created) values (?,?,?,?,NOW())',
|
||||
(string) $data['page_id'],
|
||||
$data['locale'],
|
||||
$data['content'] ?? null,
|
||||
$data['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public static function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'content' => $data['content'] ?? null,
|
||||
];
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update page_contents set ' . implode(', ', $setParts) . ', modified = NOW() where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
26
core/Repository/Content/PageRepository.php
Normal file
26
core/Repository/Content/PageRepository.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Content;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Looks up CMS page metadata by slug. */
|
||||
class PageRepository
|
||||
{
|
||||
private static function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['pages'] ?? $row;
|
||||
}
|
||||
|
||||
public static function findBySlug(string $slug): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, slug, created_by, modified_by, created, modified from pages where slug = ? limit 1',
|
||||
$slug
|
||||
);
|
||||
return self::unwrap($row);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\CustomField;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
/** Reads and writes tenant-scoped custom field definitions with type and pagination filtering. */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\CustomField;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Retrieves selectable options for tenant-scoped custom field definitions. */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\CustomField;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Manages selected option links for user custom field values (atomic replace). */
|
||||
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;
|
||||
}
|
||||
}
|
||||
121
core/Repository/CustomField/UserCustomFieldValueRepository.php
Normal file
121
core/Repository/CustomField/UserCustomFieldValueRepository.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\CustomField;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Reads and writes custom field values attached to individual users. */
|
||||
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;
|
||||
}
|
||||
}
|
||||
120
core/Repository/Mail/MailLogRepository.php
Normal file
120
core/Repository/Mail/MailLogRepository.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Mail;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Domain\Taxonomy\MailLogStatus;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
/** Logs outgoing emails with queued/sent/failed status transitions and provider message IDs. */
|
||||
class MailLogRepository implements MailLogRepositoryInterface
|
||||
{
|
||||
public function create(array $data): ?int
|
||||
{
|
||||
$id = DB::insert(
|
||||
'insert into mail_log (to_email, subject, template, status, created_at) values (?,?,?,?,NOW())',
|
||||
$data['to_email'],
|
||||
$data['subject'],
|
||||
$data['template'] ?? null,
|
||||
$data['status'] ?? MailLogStatus::Queued->value
|
||||
);
|
||||
return $id ? (int) $id : null;
|
||||
}
|
||||
|
||||
public function markSent(int $id, ?string $providerMessageId = null): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update mail_log set status = ?, sent_at = NOW(), provider_message_id = ?, error_message = NULL where id = ?',
|
||||
MailLogStatus::Sent->value,
|
||||
$providerMessageId,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function markFailed(int $id, string $errorMessage): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update mail_log set status = ?, error_message = ? where id = ?',
|
||||
MailLogStatus::Failed->value,
|
||||
$errorMessage,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function listPaged(array $options): array
|
||||
{
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$status = MailLogStatus::tryNormalize((string) ($options['status'] ?? ''))?->value ?? '';
|
||||
$createdFrom = trim((string) ($options['created_from'] ?? ''));
|
||||
$createdTo = trim((string) ($options['created_to'] ?? ''));
|
||||
|
||||
$allowedOrder = ['id', 'created_at', 'sent_at', 'to_email', 'subject', 'status'];
|
||||
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options);
|
||||
[$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder, 'created_at', 'desc');
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
RepoQuery::addLikeFilter($where, $params, ['to_email', 'subject', 'template'], $search);
|
||||
RepoQuery::addEqualsFilter($where, $params, $status, 'status = ?');
|
||||
if ($createdFrom !== '') {
|
||||
$where[] = 'created_at >= ?';
|
||||
$params[] = $createdFrom . ' 00:00:00';
|
||||
}
|
||||
if ($createdTo !== '') {
|
||||
$where[] = 'created_at <= ?';
|
||||
$params[] = $createdTo . ' 23:59:59';
|
||||
}
|
||||
|
||||
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||
$count = DB::selectValue('select count(*) from mail_log' . $whereSql, ...$params);
|
||||
$total = $count ? (int) $count : 0;
|
||||
|
||||
$query = 'select id, to_email, subject, template, status, created_at, sent_at, error_message, provider_message_id from mail_log' .
|
||||
$whereSql .
|
||||
sprintf(' order by `%s` %s limit ? offset ?', $order, $dir);
|
||||
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => $this->unwrapList($rows),
|
||||
];
|
||||
}
|
||||
|
||||
public function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, to_email, subject, template, status, created_at, sent_at, error_message, provider_message_id from mail_log where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return $this->unwrap($row);
|
||||
}
|
||||
|
||||
private function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['mail_log'] ?? null;
|
||||
}
|
||||
|
||||
private function unwrapList(mixed $rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$mailLog = $row['mail_log'] ?? null;
|
||||
if (is_array($mailLog)) {
|
||||
$list[] = $mailLog;
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
}
|
||||
17
core/Repository/Mail/MailLogRepositoryInterface.php
Normal file
17
core/Repository/Mail/MailLogRepositoryInterface.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Mail;
|
||||
|
||||
/** Contract for outgoing email log creation, status updates, and paginated listing. */
|
||||
interface MailLogRepositoryInterface
|
||||
{
|
||||
public function create(array $data): ?int;
|
||||
|
||||
public function markSent(int $id, ?string $providerMessageId = null): bool;
|
||||
|
||||
public function markFailed(int $id, string $errorMessage): bool;
|
||||
|
||||
public function listPaged(array $options): array;
|
||||
|
||||
public function find(int $id): ?array;
|
||||
}
|
||||
75
core/Repository/Module/ModuleMigrationRepository.php
Normal file
75
core/Repository/Module/ModuleMigrationRepository.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Module;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/**
|
||||
* Manages the module_migrations tracking table.
|
||||
*
|
||||
* All SQL for module migration tracking lives here (strict layering).
|
||||
*/
|
||||
final class ModuleMigrationRepository implements ModuleMigrationRepositoryInterface
|
||||
{
|
||||
public function ensureTrackingTable(): void
|
||||
{
|
||||
DB::query(
|
||||
'CREATE TABLE IF NOT EXISTS `module_migrations` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`module_id` VARCHAR(64) NOT NULL,
|
||||
`filename` VARCHAR(255) NOT NULL,
|
||||
`applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_module_migration` (`module_id`, `filename`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, true> Filenames already applied, keyed by filename
|
||||
*/
|
||||
public function getAppliedFilenames(string $moduleId): array
|
||||
{
|
||||
$applied = [];
|
||||
$rows = DB::select('SELECT filename FROM module_migrations WHERE module_id = ?', $moduleId);
|
||||
foreach ((array) $rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$filename = trim((string) (($row['module_migrations']['filename'] ?? $row['filename'] ?? '')));
|
||||
if ($filename !== '') {
|
||||
$applied[$filename] = true;
|
||||
}
|
||||
}
|
||||
return $applied;
|
||||
}
|
||||
|
||||
public function recordApplied(string $moduleId, string $filename): void
|
||||
{
|
||||
DB::query(
|
||||
'INSERT INTO module_migrations (module_id, filename) VALUES (?, ?)',
|
||||
$moduleId,
|
||||
$filename
|
||||
);
|
||||
}
|
||||
|
||||
public function beginTransaction(): void
|
||||
{
|
||||
DB::query('START TRANSACTION');
|
||||
}
|
||||
|
||||
public function commit(): void
|
||||
{
|
||||
DB::query('COMMIT');
|
||||
}
|
||||
|
||||
public function rollback(): void
|
||||
{
|
||||
DB::query('ROLLBACK');
|
||||
}
|
||||
|
||||
public function executeStatement(string $sql): void
|
||||
{
|
||||
DB::query($sql);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Module;
|
||||
|
||||
/** Contract for module migration tracking: table creation, applied-file queries, and statement execution. */
|
||||
interface ModuleMigrationRepositoryInterface
|
||||
{
|
||||
public function ensureTrackingTable(): void;
|
||||
|
||||
/**
|
||||
* @return array<string, true>
|
||||
*/
|
||||
public function getAppliedFilenames(string $moduleId): array;
|
||||
|
||||
public function recordApplied(string $moduleId, string $filename): void;
|
||||
|
||||
public function beginTransaction(): void;
|
||||
|
||||
public function commit(): void;
|
||||
|
||||
public function rollback(): void;
|
||||
|
||||
public function executeStatement(string $sql): void;
|
||||
}
|
||||
303
core/Repository/Org/DepartmentRepository.php
Normal file
303
core/Repository/Org/DepartmentRepository.php
Normal file
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Org;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
use MintyPHP\Repository\Support\RepositoryArrayHelper;
|
||||
|
||||
/** Reads and writes department records with cost center, code tracking, and pagination. */
|
||||
class DepartmentRepository implements DepartmentRepositoryInterface
|
||||
{
|
||||
public function list(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments order by id desc'
|
||||
);
|
||||
return RepositoryArrayHelper::unwrapList($rows, 'departments');
|
||||
}
|
||||
|
||||
public function listIds(): array
|
||||
{
|
||||
$rows = DB::select('select id from departments');
|
||||
|
||||
return RepositoryArrayHelper::extractIds($rows, 'departments');
|
||||
}
|
||||
|
||||
public function listActiveIdsByTenantIds(array $tenantIds): array
|
||||
{
|
||||
$tenantIds = RepositoryArrayHelper::sanitizePositiveIds($tenantIds);
|
||||
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)
|
||||
);
|
||||
|
||||
return RepositoryArrayHelper::extractIds($rows, 'departments');
|
||||
}
|
||||
|
||||
public function listByTenantIds(array $tenantIds): array
|
||||
{
|
||||
$tenantIds = RepositoryArrayHelper::sanitizePositiveIds($tenantIds);
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
|
||||
$rows = DB::select(
|
||||
'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 RepositoryArrayHelper::unwrapList($rows, 'departments');
|
||||
}
|
||||
|
||||
public function listByIds(array $departmentIds, bool $includeInactive = false): array
|
||||
{
|
||||
$departmentIds = RepositoryArrayHelper::sanitizePositiveIds($departmentIds);
|
||||
if (!$departmentIds) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($departmentIds), '?'));
|
||||
$activeSql = $includeInactive ? '' : 'active = 1 and ';
|
||||
$rows = DB::select(
|
||||
'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 RepositoryArrayHelper::unwrapList($rows, 'departments');
|
||||
}
|
||||
|
||||
public function listPaged(array $options): array
|
||||
{
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$tenant = trim((string) ($options['tenant'] ?? ''));
|
||||
$allowedOrder = ['id', 'uuid', 'description', 'code', 'cost_center', 'active', 'created', 'modified'];
|
||||
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options);
|
||||
[$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder);
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
RepoQuery::addLikeFilter(
|
||||
$where,
|
||||
$params,
|
||||
['description', 'uuid', 'code', 'cost_center'],
|
||||
$search
|
||||
);
|
||||
$activeValue = array_key_exists('active', $options) ? $options['active'] : null;
|
||||
RepoQuery::addEnumFilter($where, $params, $activeValue, [
|
||||
['aliases' => ['1', 'true', 'active'], 'sql' => 'departments.active = 1'],
|
||||
['aliases' => ['0', 'false', 'inactive'], 'sql' => 'departments.active = 0'],
|
||||
]);
|
||||
RepoQuery::addEqualsFilter(
|
||||
$where,
|
||||
$params,
|
||||
$tenant,
|
||||
"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) {
|
||||
$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) {
|
||||
$where[] = 'departments.tenant_id in (???)';
|
||||
$params[] = $tenantIds;
|
||||
} else {
|
||||
$where[] = '1=0';
|
||||
}
|
||||
}
|
||||
|
||||
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||
$count = DB::selectValue('select count(*) from departments' . $whereSql, ...$params);
|
||||
$total = $count ? (int) $count : 0;
|
||||
|
||||
$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);
|
||||
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
$list = RepositoryArrayHelper::unwrapList($rows, 'departments');
|
||||
$ids = [];
|
||||
foreach ($list as $department) {
|
||||
if (isset($department['id'])) {
|
||||
$ids[] = (int) $department['id'];
|
||||
}
|
||||
}
|
||||
$tenantLabelsByDepartment = [];
|
||||
if ($ids) {
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$labelRows = DB::select(
|
||||
'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)
|
||||
);
|
||||
foreach ((array) $labelRows as $row) {
|
||||
$departmentId = 0;
|
||||
$label = '';
|
||||
foreach ((array) $row as $table) {
|
||||
if (!is_array($table)) {
|
||||
continue;
|
||||
}
|
||||
if ($departmentId === 0 && isset($table['department_id'])) {
|
||||
$departmentId = (int) $table['department_id'];
|
||||
}
|
||||
if ($label === '' && isset($table['description'])) {
|
||||
$label = (string) $table['description'];
|
||||
}
|
||||
}
|
||||
if ($departmentId > 0 && $label !== '') {
|
||||
$tenantLabelsByDepartment[$departmentId] = [$label];
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($list as &$department) {
|
||||
$departmentId = (int) ($department['id'] ?? 0);
|
||||
$department['tenant_labels'] = $tenantLabelsByDepartment[$departmentId] ?? [];
|
||||
}
|
||||
unset($department);
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => $list,
|
||||
];
|
||||
}
|
||||
|
||||
public function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return RepositoryArrayHelper::unwrap($row, 'departments');
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, tenant_id, code, cost_center, active, created_by, modified_by, created, modified from departments where uuid = ? limit 1',
|
||||
$uuid
|
||||
);
|
||||
return RepositoryArrayHelper::unwrap($row, 'departments');
|
||||
}
|
||||
|
||||
public function existsByCode(string $code, int $excludeId = 0): bool
|
||||
{
|
||||
$code = trim($code);
|
||||
if ($code === '') {
|
||||
return false;
|
||||
}
|
||||
if ($excludeId > 0) {
|
||||
$count = DB::selectValue('select count(*) from departments where code = ? and id != ?', $code, (string) $excludeId);
|
||||
} else {
|
||||
$count = DB::selectValue('select count(*) from departments where code = ?', $code);
|
||||
}
|
||||
return (int) $count > 0;
|
||||
}
|
||||
|
||||
public function existsByTenantAndDescription(int $tenantId, string $description, int $excludeId = 0): bool
|
||||
{
|
||||
$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 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 function create(array $data): int|false
|
||||
{
|
||||
return DB::insert(
|
||||
'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,
|
||||
$data['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): bool
|
||||
{
|
||||
$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,
|
||||
];
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update departments set ' . implode(', ', $setParts) . ' where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public 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 function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from departments where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
37
core/Repository/Org/DepartmentRepositoryInterface.php
Normal file
37
core/Repository/Org/DepartmentRepositoryInterface.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Org;
|
||||
|
||||
/** Contract for department CRUD, existence checks, and filtered listing. */
|
||||
interface DepartmentRepositoryInterface
|
||||
{
|
||||
public function list(): array;
|
||||
|
||||
public function listIds(): array;
|
||||
|
||||
public function listActiveIdsByTenantIds(array $tenantIds): array;
|
||||
|
||||
public function listByTenantIds(array $tenantIds): array;
|
||||
|
||||
public function listByIds(array $departmentIds, bool $includeInactive = false): array;
|
||||
|
||||
public function listPaged(array $options): array;
|
||||
|
||||
public function find(int $id): ?array;
|
||||
|
||||
public function findByUuid(string $uuid): ?array;
|
||||
|
||||
public function existsByCode(string $code, int $excludeId = 0): bool;
|
||||
|
||||
public function existsByTenantAndDescription(int $tenantId, string $description, int $excludeId = 0): bool;
|
||||
|
||||
public function countByTenantId(int $tenantId): int;
|
||||
|
||||
public function create(array $data): int|false;
|
||||
|
||||
public function update(int $id, array $data): bool;
|
||||
|
||||
public function setTenant(int $id, int $tenantId): bool;
|
||||
|
||||
public function delete(int $id): bool;
|
||||
}
|
||||
105
core/Repository/Org/UserDepartmentRepository.php
Normal file
105
core/Repository/Org/UserDepartmentRepository.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Org;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepositoryArrayHelper;
|
||||
|
||||
/** Queries user-department assignments and counts active/inactive users per department. */
|
||||
class UserDepartmentRepository implements UserDepartmentRepositoryInterface
|
||||
{
|
||||
public function listDepartmentIdsByUserId(int $userId): array
|
||||
{
|
||||
$rows = DB::select('select department_id from user_departments where user_id = ?', (string) $userId);
|
||||
|
||||
return RepositoryArrayHelper::extractIds($rows, 'user_departments', 'department_id');
|
||||
}
|
||||
|
||||
public function replaceForUser(int $userId, array $departmentIds): bool
|
||||
{
|
||||
DB::delete('delete from user_departments where user_id = ?', (string) $userId);
|
||||
if (!$departmentIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($departmentIds as $departmentId) {
|
||||
DB::insert(
|
||||
'insert into user_departments (user_id, department_id, created) values (?,?,NOW())',
|
||||
(string) $userId,
|
||||
(string) $departmentId
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function removeInvalidForDepartment(int $departmentId): int
|
||||
{
|
||||
$query = 'delete ud from user_departments ud ' .
|
||||
'where ud.department_id = ? and not exists (' .
|
||||
'select 1 from user_tenants ut ' .
|
||||
'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 function countUsersByDepartmentIds(array $departmentIds): array
|
||||
{
|
||||
return $this->countByDepartmentIds($departmentIds, false);
|
||||
}
|
||||
|
||||
public function countActiveUsersByDepartmentIds(array $departmentIds): array
|
||||
{
|
||||
return $this->countByDepartmentIds($departmentIds, true);
|
||||
}
|
||||
|
||||
private function countByDepartmentIds(array $departmentIds, bool $activeOnly): array
|
||||
{
|
||||
$departmentIds = RepositoryArrayHelper::sanitizePositiveIds($departmentIds);
|
||||
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 = $this->extractIntField($row, 'department_id');
|
||||
if ($departmentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$result[$departmentId] = $this->extractIntField($row, 'user_count');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
}
|
||||
17
core/Repository/Org/UserDepartmentRepositoryInterface.php
Normal file
17
core/Repository/Org/UserDepartmentRepositoryInterface.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Org;
|
||||
|
||||
/** Contract for querying user-department membership and per-department user counts. */
|
||||
interface UserDepartmentRepositoryInterface
|
||||
{
|
||||
public function listDepartmentIdsByUserId(int $userId): array;
|
||||
|
||||
public function replaceForUser(int $userId, array $departmentIds): bool;
|
||||
|
||||
public function removeInvalidForDepartment(int $departmentId): int;
|
||||
|
||||
public function countUsersByDepartmentIds(array $departmentIds): array;
|
||||
|
||||
public function countActiveUsersByDepartmentIds(array $departmentIds): array;
|
||||
}
|
||||
270
core/Repository/Scheduler/ScheduledJobRepository.php
Normal file
270
core/Repository/Scheduler/ScheduledJobRepository.php
Normal file
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Scheduler;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Domain\Taxonomy\ScheduledJobStatus;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
/** Persists scheduled jobs with cron expressions, next-run calculation, and metadata updates. */
|
||||
class ScheduledJobRepository implements ScheduledJobRepositoryInterface
|
||||
{
|
||||
public 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 function find(int $id): ?array
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return null;
|
||||
}
|
||||
$row = DB::selectOne('select * from scheduled_jobs where id = ? limit 1', (string) $id);
|
||||
return $this->normalizeRow($row);
|
||||
}
|
||||
|
||||
public function findByKey(string $jobKey): ?array
|
||||
{
|
||||
$jobKey = trim($jobKey);
|
||||
if ($jobKey === '') {
|
||||
return null;
|
||||
}
|
||||
$row = DB::selectOne('select * from scheduled_jobs where job_key = ? limit 1', $jobKey);
|
||||
return $this->normalizeRow($row);
|
||||
}
|
||||
|
||||
public 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 (ScheduledJobStatus::tryNormalize($status) !== null) {
|
||||
$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 = $this->normalizeRow($row);
|
||||
if ($item !== null) {
|
||||
$normalized[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => $normalized,
|
||||
];
|
||||
}
|
||||
|
||||
public 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 = $this->normalizeRow($row);
|
||||
if ($item !== null) {
|
||||
$normalized[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
public 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 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 < ?))',
|
||||
ScheduledJobStatus::Running->value,
|
||||
$startedAtUtc,
|
||||
(string) $id,
|
||||
ScheduledJobStatus::Running->value,
|
||||
$staleBefore
|
||||
);
|
||||
return (int) $updated > 0;
|
||||
}
|
||||
|
||||
public 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;
|
||||
}
|
||||
|
||||
public function deleteOrphanedJobs(array $validKeys): int
|
||||
{
|
||||
$validKeys = array_filter(array_map('trim', $validKeys), static fn (string $k): bool => $k !== '');
|
||||
if ($validKeys === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($validKeys), '?'));
|
||||
$affected = DB::update(
|
||||
"DELETE FROM scheduled_jobs WHERE job_key NOT IN ({$placeholders})",
|
||||
...array_map('strval', $validKeys)
|
||||
);
|
||||
|
||||
return (int) $affected;
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Scheduler;
|
||||
|
||||
/** Contract for scheduled job CRUD, due-job queries, and run status tracking. */
|
||||
interface ScheduledJobRepositoryInterface
|
||||
{
|
||||
public function create(array $data): int|false;
|
||||
|
||||
public function find(int $id): ?array;
|
||||
|
||||
public function findByKey(string $jobKey): ?array;
|
||||
|
||||
public function listPaged(array $filters): array;
|
||||
|
||||
public function listDueJobs(string $nowUtc, int $limit = 20): array;
|
||||
|
||||
public function updateJobMeta(int $id, array $data): bool;
|
||||
|
||||
public function markRunning(int $id, string $startedAtUtc): bool;
|
||||
|
||||
public function finishRun(
|
||||
int $id,
|
||||
string $status,
|
||||
string $startedAtUtc,
|
||||
string $finishedAtUtc,
|
||||
?string $nextRunAtUtc,
|
||||
?string $errorCode,
|
||||
?string $errorMessage
|
||||
): bool;
|
||||
|
||||
/** Delete jobs whose job_key is not in the given list of valid keys. Returns affected row count. */
|
||||
public function deleteOrphanedJobs(array $validKeys): int;
|
||||
}
|
||||
140
core/Repository/Scheduler/ScheduledJobRunRepository.php
Normal file
140
core/Repository/Scheduler/ScheduledJobRunRepository.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Scheduler;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Domain\Taxonomy\ScheduledJobRunStatus;
|
||||
use MintyPHP\Domain\Taxonomy\ScheduledJobTriggerType;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
/** Records individual job run results with duration, output, and retention-based purging. */
|
||||
class ScheduledJobRunRepository implements ScheduledJobRunRepositoryInterface
|
||||
{
|
||||
public 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'] ?? ScheduledJobTriggerType::Scheduler->value),
|
||||
(string) ($data['status'] ?? ScheduledJobRunStatus::Failed->value),
|
||||
($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 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 (ScheduledJobRunStatus::tryNormalize($status) !== null) {
|
||||
$where[] = 'scheduled_job_runs.status = ?';
|
||||
$params[] = $status;
|
||||
}
|
||||
if (ScheduledJobTriggerType::tryNormalize($triggerType) !== null) {
|
||||
$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 = $this->normalizeRow($row);
|
||||
if ($item !== null) {
|
||||
$normalized[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ['total' => $total, 'rows' => $normalized];
|
||||
}
|
||||
|
||||
public 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 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Scheduler;
|
||||
|
||||
/** Contract for scheduled job run history: creation, pagination, and retention purge. */
|
||||
interface ScheduledJobRunRepositoryInterface
|
||||
{
|
||||
public function create(array $data): int|false;
|
||||
|
||||
public function listPagedByJobId(int $jobId, array $filters): array;
|
||||
|
||||
public function purgeOlderThanDays(int $days): int;
|
||||
}
|
||||
51
core/Repository/Scheduler/SchedulerRuntimeRepository.php
Normal file
51
core/Repository/Scheduler/SchedulerRuntimeRepository.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Scheduler;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Domain\Taxonomy\SchedulerRuntimeResult;
|
||||
|
||||
/** Tracks scheduler daemon heartbeat, result codes, and error state. */
|
||||
class SchedulerRuntimeRepository implements SchedulerRuntimeRepositoryInterface
|
||||
{
|
||||
public 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 = SchedulerRuntimeResult::normalizeOr($result, SchedulerRuntimeResult::Ok)->value;
|
||||
$errorCode = $this->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 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 function nullableTrim(?string $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
return $value !== '' ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Scheduler;
|
||||
|
||||
/** Contract for scheduler daemon heartbeat and runtime status. */
|
||||
interface SchedulerRuntimeRepositoryInterface
|
||||
{
|
||||
public function touchHeartbeat(string $result, ?string $errorCode = null, ?string $heartbeatAtUtc = null): bool;
|
||||
|
||||
public function findStatus(): ?array;
|
||||
}
|
||||
37
core/Repository/Search/SearchQueryRepository.php
Normal file
37
core/Repository/Search/SearchQueryRepository.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Search;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class SearchQueryRepository implements SearchQueryRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @param array<int, mixed> $params
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
public function selectRows(string $sql, array $params = []): array
|
||||
{
|
||||
if (trim($sql) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::select($sql, ...$params);
|
||||
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $params
|
||||
*/
|
||||
public function selectCount(string $sql, array $params = []): int
|
||||
{
|
||||
if (trim($sql) === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$value = DB::selectValue($sql, ...$params);
|
||||
|
||||
return (int) ($value ?? 0);
|
||||
}
|
||||
}
|
||||
17
core/Repository/Search/SearchQueryRepositoryInterface.php
Normal file
17
core/Repository/Search/SearchQueryRepositoryInterface.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Search;
|
||||
|
||||
interface SearchQueryRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @param array<int, mixed> $params
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
public function selectRows(string $sql, array $params = []): array;
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $params
|
||||
*/
|
||||
public function selectCount(string $sql, array $params = []): int;
|
||||
}
|
||||
70
core/Repository/Security/RateLimitRepository.php
Normal file
70
core/Repository/Security/RateLimitRepository.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Security;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Persists rate limit counters with hit tracking, window expiry, and block duration. */
|
||||
class RateLimitRepository implements RateLimitRepositoryInterface
|
||||
{
|
||||
public function findByScopeAndHash(string $scope, string $subjectHash): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, scope, subject_hash, hits, window_started_at, blocked_until, created, modified from request_rate_limits where scope = ? and subject_hash = ? limit 1',
|
||||
$scope,
|
||||
$subjectHash
|
||||
);
|
||||
if (!$row || !isset($row['request_rate_limits']) || !is_array($row['request_rate_limits'])) {
|
||||
return null;
|
||||
}
|
||||
return $row['request_rate_limits'];
|
||||
}
|
||||
|
||||
public 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 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 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;
|
||||
}
|
||||
}
|
||||
26
core/Repository/Security/RateLimitRepositoryInterface.php
Normal file
26
core/Repository/Security/RateLimitRepositoryInterface.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Security;
|
||||
|
||||
/** Contract for sliding-window rate limit storage by scope and subject hash. */
|
||||
interface RateLimitRepositoryInterface
|
||||
{
|
||||
public function findByScopeAndHash(string $scope, string $subjectHash): ?array;
|
||||
|
||||
public function create(
|
||||
string $scope,
|
||||
string $subjectHash,
|
||||
int $hits,
|
||||
string $windowStartedAt,
|
||||
?string $blockedUntil
|
||||
): bool;
|
||||
|
||||
public function updateStateById(
|
||||
int $id,
|
||||
int $hits,
|
||||
string $windowStartedAt,
|
||||
?string $blockedUntil
|
||||
): bool;
|
||||
|
||||
public function deleteByScopeAndHash(string $scope, string $subjectHash): bool;
|
||||
}
|
||||
36
core/Repository/Settings/SettingRepository.php
Normal file
36
core/Repository/Settings/SettingRepository.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Settings;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Reads and upserts application settings as key-value pairs in the database. */
|
||||
class SettingRepository implements SettingRepositoryInterface
|
||||
{
|
||||
public function find(string $key): ?array
|
||||
{
|
||||
$row = DB::selectOne('select `key`, `value`, `description`, `updated_at` from settings where `key` = ? limit 1', $key);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['settings'] ?? $row;
|
||||
}
|
||||
|
||||
public function getValue(string $key): ?string
|
||||
{
|
||||
$row = $this->find($key);
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return array_key_exists('value', $row) ? (string) $row['value'] : null;
|
||||
}
|
||||
|
||||
public function set(string $key, ?string $value, ?string $description = null): bool
|
||||
{
|
||||
$query = 'insert into settings (`key`, `value`, `description`) values (?, ?, ?) '
|
||||
. 'on duplicate key update `value` = values(`value`), '
|
||||
. '`description` = ifnull(values(`description`), `description`)';
|
||||
$result = DB::update($query, $key, $value, $description);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
13
core/Repository/Settings/SettingRepositoryInterface.php
Normal file
13
core/Repository/Settings/SettingRepositoryInterface.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Settings;
|
||||
|
||||
/** Contract for key-value application settings with optional descriptions. */
|
||||
interface SettingRepositoryInterface
|
||||
{
|
||||
public function find(string $key): ?array;
|
||||
|
||||
public function getValue(string $key): ?string;
|
||||
|
||||
public function set(string $key, ?string $value, ?string $description = null): bool;
|
||||
}
|
||||
1086
core/Repository/Stats/AdminStatsRepository.php
Normal file
1086
core/Repository/Stats/AdminStatsRepository.php
Normal file
File diff suppressed because it is too large
Load Diff
11
core/Repository/Stats/AdminStatsRepositoryInterface.php
Normal file
11
core/Repository/Stats/AdminStatsRepositoryInterface.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Stats;
|
||||
|
||||
interface AdminStatsRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildViewData(): array;
|
||||
}
|
||||
80
core/Repository/Support/DatabaseSessionRepository.php
Normal file
80
core/Repository/Support/DatabaseSessionRepository.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Support;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Executes database session operations: advisory locks, transactions, and commit/rollback. */
|
||||
class DatabaseSessionRepository implements DatabaseSessionRepositoryInterface
|
||||
{
|
||||
public function acquireAdvisoryLock(string $lockName, int $timeoutSeconds = 0): bool
|
||||
{
|
||||
$lockName = trim($lockName);
|
||||
if ($lockName === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$timeoutSeconds = max(0, (int) $timeoutSeconds);
|
||||
$got = DB::selectValue(
|
||||
'select GET_LOCK(?, ?) as got_lock',
|
||||
$lockName,
|
||||
(string) $timeoutSeconds
|
||||
);
|
||||
|
||||
return (int) $got === 1;
|
||||
}
|
||||
|
||||
public function releaseAdvisoryLock(string $lockName): void
|
||||
{
|
||||
$lockName = trim($lockName);
|
||||
if ($lockName === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::selectValue('select RELEASE_LOCK(?) as released_lock', $lockName);
|
||||
}
|
||||
|
||||
public function beginTransaction(): void
|
||||
{
|
||||
DB::handle()->begin_transaction();
|
||||
}
|
||||
|
||||
public function commitTransaction(): void
|
||||
{
|
||||
DB::handle()->commit();
|
||||
}
|
||||
|
||||
public function rollbackTransaction(): void
|
||||
{
|
||||
DB::handle()->rollback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback inside a DB transaction. Commits on success, rolls back on exception.
|
||||
*
|
||||
* Eliminates the need for manual begin/commit/rollback + rollbackQuietly() boilerplate
|
||||
* that is duplicated across multiple services. The callback receives no arguments;
|
||||
* use closures to capture dependencies.
|
||||
*
|
||||
* @template T
|
||||
* @param callable(): T $callback
|
||||
* @return T The value returned by the callback.
|
||||
* @throws \Throwable Re-throws the original exception after rollback.
|
||||
*/
|
||||
public function transaction(callable $callback): mixed
|
||||
{
|
||||
$this->beginTransaction();
|
||||
try {
|
||||
$result = $callback();
|
||||
$this->commitTransaction();
|
||||
return $result;
|
||||
} catch (\Throwable $e) {
|
||||
try {
|
||||
$this->rollbackTransaction();
|
||||
} catch (\Throwable) {
|
||||
// Swallow — the original exception is more important.
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Support;
|
||||
|
||||
/** Contract for database-level session control: advisory locks and transaction management. */
|
||||
interface DatabaseSessionRepositoryInterface
|
||||
{
|
||||
public function acquireAdvisoryLock(string $lockName, int $timeoutSeconds = 0): bool;
|
||||
|
||||
public function releaseAdvisoryLock(string $lockName): void;
|
||||
|
||||
public function beginTransaction(): void;
|
||||
|
||||
public function commitTransaction(): void;
|
||||
|
||||
public function rollbackTransaction(): void;
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param callable(): T $callback
|
||||
* @return T
|
||||
*/
|
||||
public function transaction(callable $callback): mixed;
|
||||
}
|
||||
198
core/Repository/Support/RepoQuery.php
Normal file
198
core/Repository/Support/RepoQuery.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Support;
|
||||
|
||||
/** Static helpers for safe SQL query building: LIKE escaping, limit/offset clamping, ID list normalization, and enum filtering. */
|
||||
class RepoQuery
|
||||
{
|
||||
private static function normalizeLikeValue(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
$value = str_replace('\\', '\\\\', $value);
|
||||
$value = str_replace('_', '\\_', $value);
|
||||
$value = str_replace('*', '%', $value);
|
||||
if (!str_starts_with($value, '%')) {
|
||||
$value = '%' . $value;
|
||||
}
|
||||
if (!str_ends_with($value, '%')) {
|
||||
$value = $value . '%';
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
public static function sanitizeLimitOffset(
|
||||
array $options,
|
||||
int $defaultLimit = 10,
|
||||
int $minLimit = 1,
|
||||
int $maxLimit = 100,
|
||||
int $defaultOffset = 0
|
||||
): array {
|
||||
$limit = (int) ($options['limit'] ?? $defaultLimit);
|
||||
if ($limit < $minLimit) {
|
||||
$limit = $defaultLimit;
|
||||
} elseif ($limit > $maxLimit) {
|
||||
$limit = $maxLimit;
|
||||
}
|
||||
|
||||
$offset = (int) ($options['offset'] ?? $defaultOffset);
|
||||
if ($offset < 0) {
|
||||
$offset = 0;
|
||||
}
|
||||
|
||||
return [$limit, $offset];
|
||||
}
|
||||
|
||||
public static function sanitizeOrder(
|
||||
array $options,
|
||||
array $allowedOrder,
|
||||
string $defaultOrder = 'id',
|
||||
string $defaultDir = 'desc'
|
||||
): array {
|
||||
$order = (string) ($options['order'] ?? $defaultOrder);
|
||||
$dir = strtolower((string) ($options['dir'] ?? $defaultDir));
|
||||
if (!in_array($order, $allowedOrder, true)) {
|
||||
$order = $defaultOrder;
|
||||
}
|
||||
if (!in_array($dir, ['asc', 'desc'], true)) {
|
||||
$dir = $defaultDir;
|
||||
}
|
||||
return [$order, $dir];
|
||||
}
|
||||
|
||||
public static function addLikeFilter(array &$where, array &$params, array $fields, string $value): void
|
||||
{
|
||||
$like = self::normalizeLikeValue($value);
|
||||
if ($like === '') {
|
||||
return;
|
||||
}
|
||||
$clauses = [];
|
||||
foreach ($fields as $field) {
|
||||
$clauses[] = $field . " like ? escape '\\\\'";
|
||||
$params[] = $like;
|
||||
}
|
||||
if ($clauses) {
|
||||
$where[] = '(' . implode(' or ', $clauses) . ')';
|
||||
}
|
||||
}
|
||||
|
||||
public static function addEnumFilter(array &$where, array &$params, $value, array $map): void
|
||||
{
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
$normalized = strtolower(trim((string) $value));
|
||||
if ($normalized === '' || $normalized === 'all') {
|
||||
return;
|
||||
}
|
||||
foreach ($map as $entry) {
|
||||
$aliases = $entry['aliases'] ?? [];
|
||||
$aliases = array_map('strtolower', $aliases);
|
||||
if (!in_array($normalized, $aliases, true)) {
|
||||
continue;
|
||||
}
|
||||
$sql = (string) ($entry['sql'] ?? '');
|
||||
if ($sql === '') {
|
||||
return;
|
||||
}
|
||||
$where[] = $sql;
|
||||
$paramsToAdd = $entry['params'] ?? [];
|
||||
foreach ($paramsToAdd as $param) {
|
||||
$params[] = $param;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
if ($value === null) {
|
||||
return;
|
||||
}
|
||||
$normalized = trim((string) $value);
|
||||
if ($normalized === '' && !$allowEmpty) {
|
||||
return;
|
||||
}
|
||||
$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;
|
||||
}
|
||||
|
||||
public static function normalizeStringList(mixed $value, int $max = 200, ?callable $sanitizer = null): array
|
||||
{
|
||||
if ($max <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
$items = [];
|
||||
$seen = [];
|
||||
foreach ($flat as $item) {
|
||||
$text = trim((string) $item);
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($sanitizer !== null) {
|
||||
$text = trim((string) $sanitizer($text));
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($seen[$text])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$text] = true;
|
||||
$items[] = $text;
|
||||
if (count($items) >= $max) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
}
|
||||
96
core/Repository/Support/RepositoryArrayHelper.php
Normal file
96
core/Repository/Support/RepositoryArrayHelper.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Support;
|
||||
|
||||
final class RepositoryArrayHelper
|
||||
{
|
||||
/**
|
||||
* Unwrap a single DB row from its table-name wrapper.
|
||||
*
|
||||
* MintyPHP DB layer returns rows wrapped in table-name keys, e.g. ['tenants' => [...]].
|
||||
* This extracts the inner array for the given table key.
|
||||
*/
|
||||
public static function unwrap(?array $row, string $tableKey): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $row[$tableKey] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a list of DB rows from their table-name wrappers.
|
||||
*/
|
||||
public static function unwrapList(mixed $rows, string $tableKey): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row[$tableKey] ?? null;
|
||||
if (is_array($data)) {
|
||||
$list[] = $data;
|
||||
}
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract integer IDs from DB rows, unwrapping the table-name key.
|
||||
*
|
||||
* Returns a deduplicated array of unique positive integer IDs.
|
||||
*/
|
||||
public static function extractIds(mixed $rows, string $tableKey, string $idField = 'id'): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row[$tableKey] ?? $row;
|
||||
if (is_array($data) && isset($data[$idField])) {
|
||||
$ids[] = (int) $data[$idField];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize an array of IDs to unique positive integers.
|
||||
*/
|
||||
public static function sanitizePositiveIds(array $ids): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $ids)));
|
||||
|
||||
return array_values(array_filter($ids, static fn (int $id): bool => $id > 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a MintyPHP DB row that can contain nested table-key arrays.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function flattenRow(mixed $row): array
|
||||
{
|
||||
if (!is_array($row)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$flat = [];
|
||||
foreach ($row as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$flat = array_merge($flat, $value);
|
||||
} elseif (is_string($key)) {
|
||||
$flat[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $flat;
|
||||
}
|
||||
}
|
||||
113
core/Repository/System/SystemHealthRepository.php
Normal file
113
core/Repository/System/SystemHealthRepository.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\System;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
class SystemHealthRepository implements SystemHealthRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @return bool True if DB responds to SELECT 1.
|
||||
*/
|
||||
public function checkDatabaseConnectivity(): bool
|
||||
{
|
||||
return (int) (DB::selectValue('select 1') ?? 0) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> Table names present in the current schema.
|
||||
*/
|
||||
public function listPresentTables(array $requiredTables): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select table_name from information_schema.tables where table_schema = database() and table_name in (???)',
|
||||
$requiredTables
|
||||
);
|
||||
|
||||
$present = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$table = (string) (($row['tables']['table_name'] ?? $row['table_name'] ?? ''));
|
||||
if ($table !== '') {
|
||||
$present[] = $table;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($present));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{last_heartbeat_at: string, last_result: string, last_error_code: string}|null
|
||||
*/
|
||||
public function getSchedulerStatus(): ?array
|
||||
{
|
||||
$row = DB::selectOne('select last_heartbeat_at, last_result, last_error_code from scheduler_runtime_status where id = 1 limit 1');
|
||||
$status = is_array($row) ? ($row['scheduler_runtime_status'] ?? $row) : null;
|
||||
if (!is_array($status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'last_heartbeat_at' => trim((string) ($status['last_heartbeat_at'] ?? '')),
|
||||
'last_result' => trim((string) ($status['last_result'] ?? 'unknown')),
|
||||
'last_error_code' => trim((string) ($status['last_error_code'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> Active permission keys matching the given list.
|
||||
*/
|
||||
public function listActivePermissionKeys(array $keys): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select `key` from permissions where active = 1 and `key` in (???)',
|
||||
$keys
|
||||
);
|
||||
|
||||
$present = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$key = (string) (($row['permissions']['key'] ?? $row['key'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$present[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($present));
|
||||
}
|
||||
|
||||
public function countAdminUsers(): int
|
||||
{
|
||||
return (int) (DB::selectValue(
|
||||
'select count(distinct ur.user_id) from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 where r.description in (?, ?) or r.id = 1',
|
||||
'Admin',
|
||||
'Administrator'
|
||||
) ?? 0);
|
||||
}
|
||||
|
||||
public function countActivePermissions(): int
|
||||
{
|
||||
return (int) (DB::selectValue('select count(*) from permissions where active = 1') ?? 0);
|
||||
}
|
||||
|
||||
public function countInactivePermissions(): int
|
||||
{
|
||||
return (int) (DB::selectValue('select count(*) from permissions where active = 0') ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> All active permission keys.
|
||||
*/
|
||||
public function listAllActivePermissionKeys(): array
|
||||
{
|
||||
$rows = DB::select('select `key` from permissions where active = 1 order by `key`');
|
||||
|
||||
$keys = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$key = (string) (($row['permissions']['key'] ?? $row['key'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$keys[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
}
|
||||
36
core/Repository/System/SystemHealthRepositoryInterface.php
Normal file
36
core/Repository/System/SystemHealthRepositoryInterface.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\System;
|
||||
|
||||
interface SystemHealthRepositoryInterface
|
||||
{
|
||||
public function checkDatabaseConnectivity(): bool;
|
||||
|
||||
/**
|
||||
* @param list<string> $requiredTables
|
||||
* @return list<string>
|
||||
*/
|
||||
public function listPresentTables(array $requiredTables): array;
|
||||
|
||||
/**
|
||||
* @return array{last_heartbeat_at: string, last_result: string, last_error_code: string}|null
|
||||
*/
|
||||
public function getSchedulerStatus(): ?array;
|
||||
|
||||
/**
|
||||
* @param list<string> $keys
|
||||
* @return list<string>
|
||||
*/
|
||||
public function listActivePermissionKeys(array $keys): array;
|
||||
|
||||
public function countAdminUsers(): int;
|
||||
|
||||
public function countActivePermissions(): int;
|
||||
|
||||
public function countInactivePermissions(): int;
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function listAllActivePermissionKeys(): array;
|
||||
}
|
||||
137
core/Repository/Tenant/TenantLdapAuthRepository.php
Normal file
137
core/Repository/Tenant/TenantLdapAuthRepository.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Tenant;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Persists LDAP authentication settings per tenant, including encrypted bind credentials. */
|
||||
class TenantLdapAuthRepository implements TenantLdapAuthRepositoryInterface
|
||||
{
|
||||
private const COLUMNS = 'id, tenant_id, enabled, enforce_ldap_login, host, port, encryption_mode, verify_tls_certificate, base_dn, bind_dn_enc, bind_password_enc, user_search_filter, user_search_scope, bind_method, bind_username_format, unique_id_attribute, attribute_map, sync_profile_on_login, sync_profile_fields, allowed_domains, auto_remember_mode, network_timeout, created, modified';
|
||||
|
||||
private function unwrap($row): ?array
|
||||
{
|
||||
if (!$row || !is_array($row)) {
|
||||
return null;
|
||||
}
|
||||
if (isset($row['tenant_auth_ldap']) && is_array($row['tenant_auth_ldap'])) {
|
||||
return $row['tenant_auth_ldap'];
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
public function findByTenantId(int $tenantId): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select ' . self::COLUMNS . ' from tenant_auth_ldap where tenant_id = ? limit 1',
|
||||
(string) $tenantId
|
||||
);
|
||||
return $this->unwrap($row);
|
||||
}
|
||||
|
||||
public function listByTenantIds(array $tenantIds): array
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
|
||||
$rows = DB::select(
|
||||
'select ' . self::COLUMNS . ' from tenant_auth_ldap where tenant_id in (' . $placeholders . ')',
|
||||
...array_map('strval', $tenantIds)
|
||||
);
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$item = $this->unwrap($row);
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$tenantId = (int) ($item['tenant_id'] ?? 0);
|
||||
if ($tenantId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$result[$tenantId] = $item;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function upsertByTenantId(int $tenantId, array $data): bool
|
||||
{
|
||||
$autoRememberMode = $data['auto_remember_mode'] ?? null;
|
||||
$autoRememberModeParam = $autoRememberMode !== null ? (string) (int) $autoRememberMode : null;
|
||||
$attributeMap = $data['attribute_map'] ?? '{"first_name":"givenName","last_name":"sn","email":"mail","phone":"telephoneNumber","mobile":"mobile"}';
|
||||
if (is_array($attributeMap)) {
|
||||
$attributeMap = json_encode($attributeMap, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
$result = DB::update(
|
||||
'insert into tenant_auth_ldap (tenant_id, enabled, enforce_ldap_login, host, port, encryption_mode, verify_tls_certificate, base_dn, bind_dn_enc, bind_password_enc, user_search_filter, user_search_scope, bind_method, bind_username_format, unique_id_attribute, attribute_map, sync_profile_on_login, sync_profile_fields, allowed_domains, auto_remember_mode, network_timeout, created) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW()) ' .
|
||||
'on duplicate key update enabled = values(enabled), enforce_ldap_login = values(enforce_ldap_login), host = values(host), port = values(port), encryption_mode = values(encryption_mode), verify_tls_certificate = values(verify_tls_certificate), base_dn = values(base_dn), bind_dn_enc = values(bind_dn_enc), bind_password_enc = values(bind_password_enc), ' .
|
||||
'user_search_filter = values(user_search_filter), user_search_scope = values(user_search_scope), bind_method = values(bind_method), bind_username_format = values(bind_username_format), unique_id_attribute = values(unique_id_attribute), attribute_map = values(attribute_map), ' .
|
||||
'sync_profile_on_login = values(sync_profile_on_login), sync_profile_fields = values(sync_profile_fields), allowed_domains = values(allowed_domains), auto_remember_mode = values(auto_remember_mode), network_timeout = values(network_timeout), modified = NOW()',
|
||||
(string) $tenantId,
|
||||
!empty($data['enabled']) ? '1' : '0',
|
||||
!empty($data['enforce_ldap_login']) ? '1' : '0',
|
||||
$data['host'] ?? '',
|
||||
(string) (int) ($data['port'] ?? 389),
|
||||
$data['encryption_mode'] ?? 'starttls',
|
||||
!empty($data['verify_tls_certificate']) ? '1' : '0',
|
||||
$data['base_dn'] ?? '',
|
||||
$data['bind_dn_enc'] ?? null,
|
||||
$data['bind_password_enc'] ?? null,
|
||||
$data['user_search_filter'] ?? '(&(objectClass=user)(sAMAccountName=%s))',
|
||||
$data['user_search_scope'] ?? 'sub',
|
||||
$data['bind_method'] ?? 'search_then_bind',
|
||||
$data['bind_username_format'] ?? null,
|
||||
$data['unique_id_attribute'] ?? 'objectGUID',
|
||||
$attributeMap,
|
||||
!empty($data['sync_profile_on_login']) ? '1' : '0',
|
||||
$data['sync_profile_fields'] ?? null,
|
||||
$data['allowed_domains'] ?? null,
|
||||
$autoRememberModeParam,
|
||||
(string) (int) ($data['network_timeout'] ?? 5)
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function findEnabledWithExplicitDomain(string $domain): array
|
||||
{
|
||||
$domain = strtolower(trim($domain));
|
||||
if ($domain === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::select(
|
||||
"select tenant_id, allowed_domains from tenant_auth_ldap where enabled = 1 and allowed_domains is not null and allowed_domains != '' and FIND_IN_SET(?, allowed_domains) > 0",
|
||||
$domain
|
||||
);
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$item = $this->unwrap($row);
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$tenantId = (int) ($item['tenant_id'] ?? 0);
|
||||
if ($tenantId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$result[] = ['tenant_id' => $tenantId, 'allowed_domains' => (string) ($item['allowed_domains'] ?? '')];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
16
core/Repository/Tenant/TenantLdapAuthRepositoryInterface.php
Normal file
16
core/Repository/Tenant/TenantLdapAuthRepositoryInterface.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Tenant;
|
||||
|
||||
/** Contract for storing and retrieving LDAP authentication configuration per tenant. */
|
||||
interface TenantLdapAuthRepositoryInterface
|
||||
{
|
||||
public function findByTenantId(int $tenantId): ?array;
|
||||
|
||||
public function listByTenantIds(array $tenantIds): array;
|
||||
|
||||
public function upsertByTenantId(int $tenantId, array $data): bool;
|
||||
|
||||
/** @return list<array{tenant_id: int, allowed_domains: string}> Enabled configs with explicit domain match */
|
||||
public function findEnabledWithExplicitDomain(string $domain): array;
|
||||
}
|
||||
121
core/Repository/Tenant/TenantMicrosoftAuthRepository.php
Normal file
121
core/Repository/Tenant/TenantMicrosoftAuthRepository.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Tenant;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Persists Microsoft Entra ID OAuth settings per tenant, including encrypted client secrets. */
|
||||
class TenantMicrosoftAuthRepository implements TenantMicrosoftAuthRepositoryInterface
|
||||
{
|
||||
private 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 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, auto_remember_mode, created, modified from tenant_auth_microsoft where tenant_id = ? limit 1',
|
||||
(string) $tenantId
|
||||
);
|
||||
return $this->unwrap($row);
|
||||
}
|
||||
|
||||
public function listByTenantIds(array $tenantIds): array
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
|
||||
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, auto_remember_mode, 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 = $this->unwrap($row);
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$tenantId = (int) ($item['tenant_id'] ?? 0);
|
||||
if ($tenantId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$result[$tenantId] = $item;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function upsertByTenantId(int $tenantId, array $data): bool
|
||||
{
|
||||
$autoRememberMode = $data['auto_remember_mode'] ?? null;
|
||||
$autoRememberModeParam = $autoRememberMode !== null ? (string) (int) $autoRememberMode : null;
|
||||
|
||||
$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, auto_remember_mode, 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), auto_remember_mode = values(auto_remember_mode), 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,
|
||||
$autoRememberModeParam
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function findEnabledWithExplicitDomain(string $domain): array
|
||||
{
|
||||
$domain = strtolower(trim($domain));
|
||||
if ($domain === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::select(
|
||||
"select tenant_id, allowed_domains from tenant_auth_microsoft where enabled = 1 and allowed_domains is not null and allowed_domains != '' and FIND_IN_SET(?, allowed_domains) > 0",
|
||||
$domain
|
||||
);
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$item = $this->unwrap($row);
|
||||
if (!is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
$tenantId = (int) ($item['tenant_id'] ?? 0);
|
||||
if ($tenantId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$result[] = ['tenant_id' => $tenantId, 'allowed_domains' => (string) ($item['allowed_domains'] ?? '')];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Tenant;
|
||||
|
||||
/** Contract for storing and retrieving Microsoft Entra ID SSO configuration per tenant. */
|
||||
interface TenantMicrosoftAuthRepositoryInterface
|
||||
{
|
||||
public function findByTenantId(int $tenantId): ?array;
|
||||
|
||||
public function listByTenantIds(array $tenantIds): array;
|
||||
|
||||
public function upsertByTenantId(int $tenantId, array $data): bool;
|
||||
|
||||
/** @return list<array{tenant_id: int, allowed_domains: string}> Enabled configs with explicit domain match */
|
||||
public function findEnabledWithExplicitDomain(string $domain): array;
|
||||
}
|
||||
215
core/Repository/Tenant/TenantRepository.php
Normal file
215
core/Repository/Tenant/TenantRepository.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Tenant;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Domain\Taxonomy\TenantStatus;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
use MintyPHP\Repository\Support\RepositoryArrayHelper;
|
||||
|
||||
/** Reads and writes tenant records with pagination, search, and status filtering. */
|
||||
class TenantRepository implements TenantRepositoryInterface
|
||||
{
|
||||
public function list(): array
|
||||
{
|
||||
$rows = DB::select(
|
||||
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants order by id desc'
|
||||
);
|
||||
return RepositoryArrayHelper::unwrapList($rows, 'tenants');
|
||||
}
|
||||
|
||||
public function listIds(): array
|
||||
{
|
||||
$rows = DB::select('select id from tenants');
|
||||
|
||||
return RepositoryArrayHelper::extractIds($rows, 'tenants');
|
||||
}
|
||||
|
||||
public function listByIds(array $tenantIds): array
|
||||
{
|
||||
$tenantIds = RepositoryArrayHelper::sanitizePositiveIds($tenantIds);
|
||||
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 RepositoryArrayHelper::unwrapList($rows, 'tenants');
|
||||
}
|
||||
|
||||
public function listActiveIdsByIds(array $tenantIds): array
|
||||
{
|
||||
$tenantIds = RepositoryArrayHelper::sanitizePositiveIds($tenantIds);
|
||||
if (!$tenantIds) {
|
||||
return [];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($tenantIds), '?'));
|
||||
$params = array_map('strval', $tenantIds);
|
||||
$rows = call_user_func_array(
|
||||
['MintyPHP\\DB', 'select'],
|
||||
array_merge(
|
||||
["select id from tenants where status = ? and id in ($placeholders)", TenantStatus::Active->value],
|
||||
$params
|
||||
)
|
||||
);
|
||||
return RepositoryArrayHelper::extractIds($rows, 'tenants');
|
||||
}
|
||||
|
||||
public function listPaged(array $options): array
|
||||
{
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$status = TenantStatus::tryNormalize((string) ($options['status'] ?? ''));
|
||||
$allowedOrder = ['id', 'uuid', 'description', 'status', 'created', 'modified'];
|
||||
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options);
|
||||
[$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder);
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
RepoQuery::addLikeFilter($where, $params, ['description', 'uuid'], $search);
|
||||
if ($status !== null) {
|
||||
$where[] = 'tenants.status = ?';
|
||||
$params[] = $status->value;
|
||||
}
|
||||
if (!empty($options['tenantUserId'])) {
|
||||
$tenantUserId = (int) $options['tenantUserId'];
|
||||
if ($tenantUserId > 0) {
|
||||
$where[] = 'exists (select 1 from user_tenants ut where ut.user_id = ? and ut.tenant_id = tenants.id)';
|
||||
$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, 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);
|
||||
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => RepositoryArrayHelper::unwrapList($rows, 'tenants'),
|
||||
];
|
||||
}
|
||||
|
||||
public function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return RepositoryArrayHelper::unwrap($row, 'tenants');
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, modified_by, created, modified from tenants where uuid = ? limit 1',
|
||||
$uuid
|
||||
);
|
||||
return RepositoryArrayHelper::unwrap($row, 'tenants');
|
||||
}
|
||||
|
||||
public function create(array $data): int|false
|
||||
{
|
||||
return DB::insert(
|
||||
'insert into tenants (uuid, description, address, postal_code, city, country, region, vat_id, tax_number, phone, fax, email, support_email, support_phone, billing_email, website, privacy_url, imprint_url, primary_color, default_theme, allow_user_theme, status, status_changed_at, status_changed_by, created_by, created) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW())',
|
||||
$data['uuid'] ?? RepoQuery::uuidV4(),
|
||||
$data['description'],
|
||||
$data['address'] ?? null,
|
||||
$data['postal_code'] ?? null,
|
||||
$data['city'] ?? null,
|
||||
$data['country'] ?? null,
|
||||
$data['region'] ?? null,
|
||||
$data['vat_id'] ?? null,
|
||||
$data['tax_number'] ?? null,
|
||||
$data['phone'] ?? null,
|
||||
$data['fax'] ?? null,
|
||||
$data['email'] ?? null,
|
||||
$data['support_email'] ?? null,
|
||||
$data['support_phone'] ?? null,
|
||||
$data['billing_email'] ?? null,
|
||||
$data['website'] ?? null,
|
||||
$data['privacy_url'] ?? null,
|
||||
$data['imprint_url'] ?? null,
|
||||
$data['primary_color'] ?? null,
|
||||
$data['default_theme'] ?? null,
|
||||
$data['allow_user_theme'] ?? null,
|
||||
$data['status'] ?? TenantStatus::Active->value,
|
||||
$data['status_changed_at'] ?? null,
|
||||
$data['status_changed_by'] ?? null,
|
||||
$data['created_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'description' => $data['description'],
|
||||
'address' => $data['address'] ?? null,
|
||||
'postal_code' => $data['postal_code'] ?? null,
|
||||
'city' => $data['city'] ?? null,
|
||||
'country' => $data['country'] ?? null,
|
||||
'region' => $data['region'] ?? null,
|
||||
'vat_id' => $data['vat_id'] ?? null,
|
||||
'tax_number' => $data['tax_number'] ?? null,
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'fax' => $data['fax'] ?? null,
|
||||
'email' => $data['email'] ?? null,
|
||||
'support_email' => $data['support_email'] ?? null,
|
||||
'support_phone' => $data['support_phone'] ?? null,
|
||||
'billing_email' => $data['billing_email'] ?? null,
|
||||
'website' => $data['website'] ?? null,
|
||||
'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'] ?? TenantStatus::Active->value,
|
||||
];
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
if (array_key_exists('status_changed_at', $data)) {
|
||||
$fields['status_changed_at'] = $data['status_changed_at'];
|
||||
}
|
||||
if (array_key_exists('status_changed_by', $data)) {
|
||||
$fields['status_changed_by'] = $data['status_changed_by'];
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update tenants set ' . implode(', ', $setParts) . ' where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from tenants where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
}
|
||||
27
core/Repository/Tenant/TenantRepositoryInterface.php
Normal file
27
core/Repository/Tenant/TenantRepositoryInterface.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Tenant;
|
||||
|
||||
/** Contract for tenant CRUD and filtered listing. */
|
||||
interface TenantRepositoryInterface
|
||||
{
|
||||
public function list(): array;
|
||||
|
||||
public function listIds(): array;
|
||||
|
||||
public function listByIds(array $tenantIds): array;
|
||||
|
||||
public function listActiveIdsByIds(array $tenantIds): array;
|
||||
|
||||
public function listPaged(array $options): array;
|
||||
|
||||
public function find(int $id): ?array;
|
||||
|
||||
public function findByUuid(string $uuid): ?array;
|
||||
|
||||
public function create(array $data): int|false;
|
||||
|
||||
public function update(int $id, array $data): bool;
|
||||
|
||||
public function delete(int $id): bool;
|
||||
}
|
||||
130
core/Repository/Tenant/UserTenantRepository.php
Normal file
130
core/Repository/Tenant/UserTenantRepository.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Tenant;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Queries the many-to-many link between users and tenants. */
|
||||
class UserTenantRepository implements UserTenantRepositoryInterface
|
||||
{
|
||||
public function listTenantIdsByUserId(int $userId): array
|
||||
{
|
||||
$rows = DB::select('select tenant_id from user_tenants where user_id = ?', (string) $userId);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_tenants'] ?? $row;
|
||||
if (is_array($data) && isset($data['tenant_id'])) {
|
||||
$ids[] = (int) $data['tenant_id'];
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public function replaceForUser(int $userId, array $tenantIds): bool
|
||||
{
|
||||
DB::delete('delete from user_tenants where user_id = ?', (string) $userId);
|
||||
if (!$tenantIds) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
DB::insert(
|
||||
'insert into user_tenants (user_id, tenant_id, created) values (?,?,NOW())',
|
||||
(string) $userId,
|
||||
(string) $tenantId
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function countUsersByTenantIds(array $tenantIds): array
|
||||
{
|
||||
return $this->countByTenantIds($tenantIds, false);
|
||||
}
|
||||
|
||||
public function countActiveUsersByTenantIds(array $tenantIds): array
|
||||
{
|
||||
return $this->countByTenantIds($tenantIds, true);
|
||||
}
|
||||
|
||||
private function countByTenantIds(array $tenantIds, bool $activeOnly): array
|
||||
{
|
||||
$tenantIds = array_values(array_unique(array_map('intval', $tenantIds)));
|
||||
$tenantIds = array_values(array_filter($tenantIds, static fn ($id) => $id > 0));
|
||||
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 = $this->extractIntField($row, 'tenant_id');
|
||||
if ($tenantId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$result[$tenantId] = $this->extractIntField($row, 'user_count');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
public function listActiveUserIdsByTenantId(int $tenantId): array
|
||||
{
|
||||
if ($tenantId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::select(
|
||||
'select ut.user_id from user_tenants ut join users u on u.id = ut.user_id and u.active = 1 where ut.tenant_id = ?',
|
||||
(string) $tenantId
|
||||
);
|
||||
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['user_tenants'] ?? $row;
|
||||
if (is_array($data) && isset($data['user_id'])) {
|
||||
$ids[] = (int) $data['user_id'];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
private 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;
|
||||
}
|
||||
}
|
||||
18
core/Repository/Tenant/UserTenantRepositoryInterface.php
Normal file
18
core/Repository/Tenant/UserTenantRepositoryInterface.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\Tenant;
|
||||
|
||||
/** Contract for querying user-tenant membership and per-tenant user counts. */
|
||||
interface UserTenantRepositoryInterface
|
||||
{
|
||||
public function listTenantIdsByUserId(int $userId): array;
|
||||
|
||||
public function replaceForUser(int $userId, array $tenantIds): bool;
|
||||
|
||||
public function countUsersByTenantIds(array $tenantIds): array;
|
||||
|
||||
public function countActiveUsersByTenantIds(array $tenantIds): array;
|
||||
|
||||
/** @return list<int> */
|
||||
public function listActiveUserIdsByTenantId(int $tenantId): array;
|
||||
}
|
||||
56
core/Repository/User/UserExternalIdentityRepository.php
Normal file
56
core/Repository/User/UserExternalIdentityRepository.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Links and looks up external identity provider accounts (Entra ID / OIDC) for users. */
|
||||
class UserExternalIdentityRepository implements UserExternalIdentityRepositoryInterface
|
||||
{
|
||||
private 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 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 $this->unwrap($row);
|
||||
}
|
||||
|
||||
public function findByProviderIssuerSubject(string $provider, string $issuer, string $subject): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, user_id, provider, oid, tid, issuer, subject, email_at_link_time, created from user_external_identities where provider = ? and issuer = ? and subject = ? limit 1',
|
||||
$provider,
|
||||
$issuer,
|
||||
$subject
|
||||
);
|
||||
return $this->unwrap($row);
|
||||
}
|
||||
|
||||
public function createLink(array $data): mixed
|
||||
{
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
/** Contract for linking external identity provider accounts (OIDC) to users. */
|
||||
interface UserExternalIdentityRepositoryInterface
|
||||
{
|
||||
public function findByProviderTidOid(string $provider, string $tid, string $oid): ?array;
|
||||
|
||||
public function findByProviderIssuerSubject(string $provider, string $issuer, string $subject): ?array;
|
||||
|
||||
public function createLink(array $data): mixed;
|
||||
}
|
||||
385
core/Repository/User/UserListQueryRepository.php
Normal file
385
core/Repository/User/UserListQueryRepository.php
Normal file
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
/** Builds and executes complex user list queries with search, date, role, department, and custom field filters. */
|
||||
class UserListQueryRepository implements UserListQueryRepositoryInterface
|
||||
{
|
||||
private function buildUserFilters(array $options): array
|
||||
{
|
||||
$search = trim((string) ($options['search'] ?? ''));
|
||||
$active = $options['active'] ?? null;
|
||||
$createdFrom = trim((string) ($options['created_from'] ?? ''));
|
||||
$createdTo = trim((string) ($options['created_to'] ?? ''));
|
||||
$tenant = trim((string) ($options['tenant'] ?? ''));
|
||||
$tenantUuids = RepoQuery::normalizeStringList($options['tenants'] ?? []);
|
||||
$roleIds = RepoQuery::normalizeIdList($options['roles'] ?? []);
|
||||
$departmentIds = RepoQuery::normalizeIdList($options['departments'] ?? []);
|
||||
$emailVerified = $options['email_verified'] ?? null;
|
||||
$loginStatus = $options['login_status'] ?? null;
|
||||
$customFieldFilterSpec = is_array($options['customFieldFilterSpec'] ?? null)
|
||||
? $options['customFieldFilterSpec']
|
||||
: [];
|
||||
$customFieldFilters = is_array($customFieldFilterSpec['filters'] ?? null)
|
||||
? $customFieldFilterSpec['filters']
|
||||
: [];
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
RepoQuery::addLikeFilter(
|
||||
$where,
|
||||
$params,
|
||||
['users.first_name', 'users.last_name', 'users.email'],
|
||||
$search
|
||||
);
|
||||
RepoQuery::addEnumFilter($where, $params, $active, [
|
||||
['aliases' => ['1', 'true', 'active'], 'sql' => 'users.active = ?', 'params' => ['1']],
|
||||
['aliases' => ['0', 'false', 'inactive'], 'sql' => 'users.active = ?', 'params' => ['0']],
|
||||
]);
|
||||
|
||||
if ($createdFrom !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdFrom)) {
|
||||
$where[] = 'users.created >= ?';
|
||||
$params[] = $createdFrom . ' 00:00:00';
|
||||
}
|
||||
if ($createdTo !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $createdTo)) {
|
||||
$where[] = 'users.created <= ?';
|
||||
$params[] = $createdTo . ' 23:59:59';
|
||||
}
|
||||
if ($tenantUuids) {
|
||||
$where[] = 'exists (select 1 from user_tenants ut2 join tenants t2 on t2.id = ut2.tenant_id where ut2.user_id = users.id and t2.uuid in (???))';
|
||||
$params[] = array_values($tenantUuids);
|
||||
} else {
|
||||
RepoQuery::addEqualsFilter(
|
||||
$where,
|
||||
$params,
|
||||
$tenant,
|
||||
'exists (select 1 from user_tenants ut2 join tenants t2 on t2.id = ut2.tenant_id where ut2.user_id = users.id and t2.uuid = ?)'
|
||||
);
|
||||
}
|
||||
if ($roleIds) {
|
||||
$where[] = 'exists (select 1 from user_roles ur2 where ur2.user_id = users.id and ur2.role_id in (???))';
|
||||
$params[] = array_map('strval', $roleIds);
|
||||
}
|
||||
if ($departmentIds) {
|
||||
$where[] = 'exists (select 1 from user_departments ud2 where ud2.user_id = users.id and ud2.department_id in (???))';
|
||||
$params[] = array_map('strval', $departmentIds);
|
||||
}
|
||||
RepoQuery::addEnumFilter($where, $params, $emailVerified, [
|
||||
['aliases' => ['1', 'true', 'verified', 'yes'], 'sql' => 'users.email_verified_at is not null'],
|
||||
['aliases' => ['0', 'false', 'unverified', 'no'], 'sql' => 'users.email_verified_at is null'],
|
||||
]);
|
||||
RepoQuery::addEnumFilter($where, $params, $loginStatus, [
|
||||
['aliases' => ['never', 'none', 'no'], 'sql' => 'users.last_login_at is null'],
|
||||
['aliases' => ['ever', 'logged', 'yes'], 'sql' => 'users.last_login_at is not null'],
|
||||
]);
|
||||
if (!empty($customFieldFilters['select']) && is_array($customFieldFilters['select'])) {
|
||||
foreach ($customFieldFilters['select'] as $definitionId => $optionId) {
|
||||
$definitionId = (int) $definitionId;
|
||||
$optionId = (int) $optionId;
|
||||
if ($definitionId <= 0 || $optionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$where[] = 'exists (select 1 from user_custom_field_values ucfv ' .
|
||||
'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.option_id = ?)';
|
||||
$params[] = (string) $definitionId;
|
||||
$params[] = (string) $optionId;
|
||||
}
|
||||
}
|
||||
if (!empty($customFieldFilters['boolean']) && is_array($customFieldFilters['boolean'])) {
|
||||
foreach ($customFieldFilters['boolean'] as $definitionId => $boolValue) {
|
||||
$definitionId = (int) $definitionId;
|
||||
$boolValue = (int) $boolValue;
|
||||
if ($definitionId <= 0 || ($boolValue !== 0 && $boolValue !== 1)) {
|
||||
continue;
|
||||
}
|
||||
$where[] = 'exists (select 1 from user_custom_field_values ucfv ' .
|
||||
'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.value_bool = ?)';
|
||||
$params[] = (string) $definitionId;
|
||||
$params[] = (string) $boolValue;
|
||||
}
|
||||
}
|
||||
if (!empty($customFieldFilters['multiselect']) && is_array($customFieldFilters['multiselect'])) {
|
||||
foreach ($customFieldFilters['multiselect'] as $definitionId => $optionIds) {
|
||||
$definitionId = (int) $definitionId;
|
||||
$optionIds = RepoQuery::normalizeIdList($optionIds);
|
||||
if ($definitionId <= 0 || !$optionIds) {
|
||||
continue;
|
||||
}
|
||||
$where[] = 'exists (select 1 from user_custom_field_values ucfv ' .
|
||||
'join user_custom_field_value_options ucfvo on ucfvo.value_id = ucfv.id ' .
|
||||
'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfvo.option_id in (???))';
|
||||
$params[] = (string) $definitionId;
|
||||
$params[] = array_map('strval', $optionIds);
|
||||
}
|
||||
}
|
||||
if (!empty($customFieldFilters['date']) && is_array($customFieldFilters['date'])) {
|
||||
foreach ($customFieldFilters['date'] as $definitionId => $bounds) {
|
||||
$definitionId = (int) $definitionId;
|
||||
$from = is_array($bounds) ? trim((string) ($bounds['from'] ?? '')) : '';
|
||||
$to = is_array($bounds) ? trim((string) ($bounds['to'] ?? '')) : '';
|
||||
if ($definitionId <= 0 || ($from === '' && $to === '')) {
|
||||
continue;
|
||||
}
|
||||
if ($from !== '') {
|
||||
$where[] = 'exists (select 1 from user_custom_field_values ucfv ' .
|
||||
'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.value_date >= ?)';
|
||||
$params[] = (string) $definitionId;
|
||||
$params[] = $from;
|
||||
}
|
||||
if ($to !== '') {
|
||||
$where[] = 'exists (select 1 from user_custom_field_values ucfv ' .
|
||||
'where ucfv.user_id = users.id and ucfv.definition_id = ? and ucfv.value_date <= ?)';
|
||||
$params[] = (string) $definitionId;
|
||||
$params[] = $to;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($options['tenantUserId'])) {
|
||||
$tenantUserId = (int) $options['tenantUserId'];
|
||||
if ($tenantUserId > 0) {
|
||||
if (defined('TENANT_SCOPE_STRICT') && TENANT_SCOPE_STRICT) {
|
||||
$where[] = 'exists (select 1 from user_tenants ut join user_tenants ut2 on ut2.tenant_id = ut.tenant_id ' .
|
||||
"join tenants t on t.id = ut.tenant_id and t.status = 'active' " .
|
||||
'where ut2.user_id = ? and ut.user_id = users.id)';
|
||||
$params[] = (string) $tenantUserId;
|
||||
} else {
|
||||
$where[] = '(not exists (select 1 from user_tenants ut where ut.user_id = users.id) ' .
|
||||
'or exists (select 1 from user_tenants ut join user_tenants ut2 on ut2.tenant_id = ut.tenant_id ' .
|
||||
"join tenants t on t.id = ut.tenant_id and t.status = 'active' " .
|
||||
'where ut2.user_id = ? and ut.user_id = users.id))';
|
||||
$params[] = (string) $tenantUserId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$whereSql = $where ? (' where ' . implode(' and ', $where)) : '';
|
||||
return [$whereSql, $params];
|
||||
}
|
||||
|
||||
private function buildListQuery(string $whereSql, string $order, string $dir): string
|
||||
{
|
||||
return 'select users.id, users.uuid, users.first_name, users.last_name, users.display_name, users.email, users.profile_description, users.job_title, users.phone, users.mobile, users.short_dial, users.address, users.postal_code, users.city, users.country, users.region, users.hire_date, users.theme, users.primary_tenant_id, users.created_by, users.modified_by, users.created, users.modified, users.last_login_at, users.last_login_provider, users.active, ' .
|
||||
'pt.description as primary_tenant_label ' .
|
||||
"from users left join tenants pt on pt.id = users.primary_tenant_id and pt.status = 'active'" .
|
||||
$whereSql .
|
||||
sprintf(' order by `users`.`%s` %s limit ? offset ?', $order, $dir);
|
||||
}
|
||||
|
||||
private function extractIds(array $list): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($list as $user) {
|
||||
if (isset($user['id'])) {
|
||||
$ids[] = (int) $user['id'];
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private function collectLabels(array $rows, string $userKey, string $labelKey): array
|
||||
{
|
||||
$labelsByUser = [];
|
||||
foreach ($rows as $row) {
|
||||
$userId = 0;
|
||||
$label = '';
|
||||
if (isset($row[$userKey]) && is_array($row[$userKey])) {
|
||||
$userId = (int) ($row[$userKey]['user_id'] ?? 0);
|
||||
}
|
||||
if (isset($row[$labelKey]) && is_array($row[$labelKey])) {
|
||||
$label = (string) ($row[$labelKey]['description'] ?? '');
|
||||
}
|
||||
if ($userId === 0 || $label === '') {
|
||||
foreach ($row as $value) {
|
||||
if (!is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
if ($userId === 0 && isset($value['user_id'])) {
|
||||
$userId = (int) $value['user_id'];
|
||||
}
|
||||
if ($label === '' && isset($value['description'])) {
|
||||
$label = (string) $value['description'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($userId === 0 && isset($row['user_id'])) {
|
||||
$userId = (int) $row['user_id'];
|
||||
}
|
||||
if ($label === '' && isset($row['description'])) {
|
||||
$label = (string) $row['description'];
|
||||
}
|
||||
if ($userId > 0 && $label !== '') {
|
||||
$labelsByUser[$userId] ??= [];
|
||||
$labelsByUser[$userId][] = $label;
|
||||
}
|
||||
}
|
||||
return $labelsByUser;
|
||||
}
|
||||
|
||||
private function hydrateUserLabels(array $list, array $options): array
|
||||
{
|
||||
$ids = $this->extractIds($list);
|
||||
if (!$ids) {
|
||||
return $list;
|
||||
}
|
||||
|
||||
$scopeUserId = (int) ($options['tenantUserId'] ?? 0);
|
||||
$scopeToActiveTenants = $scopeUserId > 0;
|
||||
$tenantLabelJoin = $scopeToActiveTenants
|
||||
? "join tenants t on t.id = ut.tenant_id and t.status = 'active' "
|
||||
: 'join tenants t on t.id = ut.tenant_id ';
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$tenantScopeSql = '';
|
||||
$tenantScopeParams = [];
|
||||
if ($scopeToActiveTenants) {
|
||||
$tenantScopeSql = ' and exists (select 1 from user_tenants uts ' .
|
||||
"join tenants ts on ts.id = uts.tenant_id and ts.status = 'active' " .
|
||||
'where uts.user_id = ? and uts.tenant_id = ut.tenant_id)';
|
||||
$tenantScopeParams[] = (string) $scopeUserId;
|
||||
}
|
||||
|
||||
$labelSql = 'select ut.user_id as user_id, t.id as tenant_id, t.description as description from user_tenants ut ' . $tenantLabelJoin .
|
||||
'where ut.user_id in (' . $placeholders . ')' . $tenantScopeSql . ' order by t.description asc';
|
||||
$labelRows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge(
|
||||
[$labelSql],
|
||||
array_merge(array_map('strval', $ids), $tenantScopeParams)
|
||||
));
|
||||
$roleRows = DB::select(
|
||||
'select ur.user_id as user_id, r.description as description from user_roles ur join roles r on r.id = ur.role_id and r.active = 1 ' .
|
||||
'where ur.user_id in (' . $placeholders . ') order by r.description asc',
|
||||
...array_map('strval', $ids)
|
||||
);
|
||||
|
||||
$departmentScopeSql = '';
|
||||
$departmentScopeParams = [];
|
||||
if ($scopeToActiveTenants) {
|
||||
$departmentScopeSql = ' and exists (select 1 from user_tenants uts ' .
|
||||
"join tenants ts on ts.id = uts.tenant_id and ts.status = 'active' " .
|
||||
'where uts.user_id = ? and uts.tenant_id = d.tenant_id)';
|
||||
$departmentScopeParams[] = (string) $scopeUserId;
|
||||
}
|
||||
$departmentLabelSql = 'select ud.user_id as user_id, d.description as description from user_departments ud ' .
|
||||
'join departments d on d.id = ud.department_id and d.active = 1 ' .
|
||||
'where ud.user_id in (' . $placeholders . ')' . $departmentScopeSql . ' order by d.description asc';
|
||||
$departmentRows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge(
|
||||
[$departmentLabelSql],
|
||||
array_merge(array_map('strval', $ids), $departmentScopeParams)
|
||||
));
|
||||
|
||||
$tenantMapByUser = [];
|
||||
foreach ($labelRows as $row) {
|
||||
$userId = 0;
|
||||
$tenantId = 0;
|
||||
$label = '';
|
||||
if (isset($row['ut']) && is_array($row['ut'])) {
|
||||
$userId = (int) ($row['ut']['user_id'] ?? 0);
|
||||
}
|
||||
if (isset($row['t']) && is_array($row['t'])) {
|
||||
$tenantId = (int) ($row['t']['tenant_id'] ?? 0);
|
||||
$label = (string) ($row['t']['description'] ?? '');
|
||||
}
|
||||
if ($userId === 0 || $tenantId === 0 || $label === '') {
|
||||
foreach ($row as $value) {
|
||||
if (!is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
if ($userId === 0 && isset($value['user_id'])) {
|
||||
$userId = (int) $value['user_id'];
|
||||
}
|
||||
if ($tenantId === 0 && isset($value['tenant_id'])) {
|
||||
$tenantId = (int) $value['tenant_id'];
|
||||
}
|
||||
if ($label === '' && isset($value['description'])) {
|
||||
$label = (string) $value['description'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($userId === 0 && isset($row['user_id'])) {
|
||||
$userId = (int) $row['user_id'];
|
||||
}
|
||||
if ($tenantId === 0 && isset($row['tenant_id'])) {
|
||||
$tenantId = (int) $row['tenant_id'];
|
||||
}
|
||||
if ($label === '' && isset($row['description'])) {
|
||||
$label = (string) $row['description'];
|
||||
}
|
||||
if ($userId > 0 && $tenantId > 0 && $label !== '') {
|
||||
$tenantMapByUser[$userId] ??= [];
|
||||
$tenantMapByUser[$userId][$tenantId] = $label;
|
||||
}
|
||||
}
|
||||
$tenantLabelsByUser = [];
|
||||
foreach ($tenantMapByUser as $userId => $map) {
|
||||
$tenantLabelsByUser[$userId] = array_values($map);
|
||||
}
|
||||
$roleLabelsByUser = $this->collectLabels($roleRows, 'ur', 'r');
|
||||
$departmentLabelsByUser = $this->collectLabels($departmentRows, 'ud', 'd');
|
||||
|
||||
foreach ($list as &$user) {
|
||||
$userId = (int) ($user['id'] ?? 0);
|
||||
$primaryId = (int) ($user['primary_tenant_id'] ?? 0);
|
||||
$user['tenant_labels'] = $tenantLabelsByUser[$userId] ?? [];
|
||||
if ($primaryId > 0 && isset($tenantMapByUser[$userId][$primaryId])) {
|
||||
$user['primary_tenant_label'] = $tenantMapByUser[$userId][$primaryId];
|
||||
}
|
||||
$user['role_labels'] = $roleLabelsByUser[$userId] ?? [];
|
||||
$user['department_labels'] = $departmentLabelsByUser[$userId] ?? [];
|
||||
}
|
||||
unset($user);
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
private function unwrapList($rows): array
|
||||
{
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $row) {
|
||||
$user = $row['users'] ?? null;
|
||||
if (is_array($user)) {
|
||||
foreach ($row as $key => $value) {
|
||||
if ($key === 'users' || is_array($value)) {
|
||||
continue;
|
||||
}
|
||||
$user[$key] = $value;
|
||||
}
|
||||
if (isset($row['pt']) && is_array($row['pt'])) {
|
||||
$label = (string) ($row['pt']['description'] ?? '');
|
||||
if ($label !== '') {
|
||||
$user['primary_tenant_label'] = $label;
|
||||
}
|
||||
}
|
||||
$list[] = $user;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
public function listPaged(array $options): array
|
||||
{
|
||||
$allowedOrder = ['id', 'uuid', 'first_name', 'last_name', 'display_name', 'email', 'created', 'modified', 'active', 'last_login_at'];
|
||||
[$limit, $offset] = RepoQuery::sanitizeLimitOffset($options);
|
||||
[$order, $dir] = RepoQuery::sanitizeOrder($options, $allowedOrder);
|
||||
[$whereSql, $params] = $this->buildUserFilters($options);
|
||||
|
||||
$count = DB::selectValue('select count(*) from users' . $whereSql, ...$params);
|
||||
$total = $count ? (int) $count : 0;
|
||||
|
||||
$query = $this->buildListQuery($whereSql, $order, $dir);
|
||||
$queryParams = array_merge($params, [(string) $limit, (string) $offset]);
|
||||
$rows = call_user_func_array(['MintyPHP\\DB', 'select'], array_merge([$query], $queryParams));
|
||||
|
||||
$list = $this->unwrapList($rows);
|
||||
$list = $this->hydrateUserLabels($list, $options);
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'rows' => $list,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
/** Contract for paginated user listing with multi-criteria filtering. */
|
||||
interface UserListQueryRepositoryInterface
|
||||
{
|
||||
public function listPaged(array $options): array;
|
||||
}
|
||||
155
core/Repository/User/UserReadRepository.php
Normal file
155
core/Repository/User/UserReadRepository.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
use MintyPHP\DB;
|
||||
|
||||
/** Retrieves user records and authorization snapshots; no write operations. */
|
||||
class UserReadRepository implements UserReadRepositoryInterface
|
||||
{
|
||||
public function findAuthzSnapshot(int $userId): ?array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = DB::selectOne(
|
||||
'select id, active, authz_version, locale, theme, current_tenant_id from users where id = ? limit 1',
|
||||
(string) $userId
|
||||
);
|
||||
|
||||
return $this->unwrap($row);
|
||||
}
|
||||
|
||||
public function find(int $id): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, last_login_at, last_login_provider, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, modified_by, created, modified, active, authz_version from users where id = ? limit 1',
|
||||
(string) $id
|
||||
);
|
||||
return $this->unwrap($row);
|
||||
}
|
||||
|
||||
public function findByUuid(string $uuid): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, last_login_at, last_login_provider, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, modified_by, created, modified, active, authz_version, active_changed_at, active_changed_by from users where uuid = ? limit 1',
|
||||
$uuid
|
||||
);
|
||||
return $this->unwrap($row);
|
||||
}
|
||||
|
||||
public function findByEmail(string $email): ?array
|
||||
{
|
||||
$row = DB::selectOne(
|
||||
'select id, uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, email_verified_at, last_login_at, last_login_provider, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, modified_by, created, modified, active, authz_version, active_changed_at, active_changed_by from users where email = ? limit 1',
|
||||
$email
|
||||
);
|
||||
return $this->unwrap($row);
|
||||
}
|
||||
|
||||
public function listPrivilegedUserIdsByPermissionKeys(array $permissionKeys): array
|
||||
{
|
||||
$keys = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($key) => trim((string) $key),
|
||||
$permissionKeys
|
||||
))));
|
||||
if (!$keys) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($keys), '?'));
|
||||
$rows = DB::select(
|
||||
'select distinct ur.user_id from user_roles ur ' .
|
||||
'join roles r on r.id = ur.role_id and r.active = 1 ' .
|
||||
'join role_permissions rp on rp.role_id = ur.role_id ' .
|
||||
'join permissions p on p.id = rp.permission_id and p.active = 1 ' .
|
||||
"where p.`key` in ($placeholders)",
|
||||
...$keys
|
||||
);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['ur'] ?? $row['user_roles'] ?? $row;
|
||||
$userId = (int) ($data['user_id'] ?? $row['user_id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
$ids[] = $userId;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public function listIdsForAutoDeactivate(int $days, array $excludedUserIds, int $limit = 500): array
|
||||
{
|
||||
if ($days <= 0 || $limit <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$excluded = array_values(array_unique(array_filter(array_map('intval', $excludedUserIds), static fn ($id) => $id > 0)));
|
||||
$query = 'select id from users where active = 1 and coalesce(last_login_at, created) <= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ' .
|
||||
(int) $days .
|
||||
' DAY)';
|
||||
$params = [];
|
||||
if ($excluded) {
|
||||
$placeholders = implode(',', array_fill(0, count($excluded), '?'));
|
||||
$query .= " and id not in ($placeholders)";
|
||||
$params = array_map('strval', $excluded);
|
||||
}
|
||||
$query .= ' order by coalesce(last_login_at, created) asc limit ?';
|
||||
$params[] = (string) $limit;
|
||||
$rows = DB::select($query, ...$params);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
return $this->extractIdList($rows);
|
||||
}
|
||||
|
||||
public function listIdsForAutoDelete(int $days, array $excludedUserIds, int $limit = 500): array
|
||||
{
|
||||
if ($days <= 0 || $limit <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$excluded = array_values(array_unique(array_filter(array_map('intval', $excludedUserIds), static fn ($id) => $id > 0)));
|
||||
$query = 'select id from users where active = 0 and active_changed_at is not null and active_changed_at <= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ' .
|
||||
(int) $days .
|
||||
' DAY)';
|
||||
$params = [];
|
||||
if ($excluded) {
|
||||
$placeholders = implode(',', array_fill(0, count($excluded), '?'));
|
||||
$query .= " and id not in ($placeholders)";
|
||||
$params = array_map('strval', $excluded);
|
||||
}
|
||||
$query .= ' order by active_changed_at asc limit ?';
|
||||
$params[] = (string) $limit;
|
||||
$rows = DB::select($query, ...$params);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
return $this->extractIdList($rows);
|
||||
}
|
||||
|
||||
private function unwrap(?array $row): ?array
|
||||
{
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return $row['users'] ?? null;
|
||||
}
|
||||
|
||||
private function extractIdList(array $rows): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($rows as $row) {
|
||||
$data = $row['users'] ?? $row;
|
||||
$id = (int) ($data['id'] ?? $row['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
}
|
||||
21
core/Repository/User/UserReadRepositoryInterface.php
Normal file
21
core/Repository/User/UserReadRepositoryInterface.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
/** Contract for read-only user lookups by ID, UUID, email, and authorization snapshots. */
|
||||
interface UserReadRepositoryInterface
|
||||
{
|
||||
public function findAuthzSnapshot(int $userId): ?array;
|
||||
|
||||
public function find(int $id): ?array;
|
||||
|
||||
public function findByUuid(string $uuid): ?array;
|
||||
|
||||
public function findByEmail(string $email): ?array;
|
||||
|
||||
public function listPrivilegedUserIdsByPermissionKeys(array $permissionKeys): array;
|
||||
|
||||
public function listIdsForAutoDeactivate(int $days, array $excludedUserIds, int $limit = 500): array;
|
||||
|
||||
public function listIdsForAutoDelete(int $days, array $excludedUserIds, int $limit = 500): array;
|
||||
}
|
||||
320
core/Repository/User/UserWriteRepository.php
Normal file
320
core/Repository/User/UserWriteRepository.php
Normal file
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
use MintyPHP\DB;
|
||||
use MintyPHP\Repository\Support\RepoQuery;
|
||||
|
||||
/** Creates and mutates user records including activation, tenant links, and preferences. */
|
||||
class UserWriteRepository implements UserWriteRepositoryInterface
|
||||
{
|
||||
public function updateLastLogin(int $userId, string $provider = 'local'): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
$provider = trim(strtolower($provider));
|
||||
if (!in_array($provider, ['local', 'microsoft'], true)) {
|
||||
$provider = 'local';
|
||||
}
|
||||
DB::query('update users set last_login_at = UTC_TIMESTAMP(), last_login_provider = ? where id = ?', $provider, (string) $userId);
|
||||
}
|
||||
|
||||
public function bumpAuthzVersion(int $userId): bool
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = DB::update(
|
||||
'update users set authz_version = authz_version + 1 where id = ?',
|
||||
(string) $userId
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function bumpAuthzVersionByUserIds(array $userIds): int
|
||||
{
|
||||
$userIds = array_values(array_unique(array_map('intval', $userIds)));
|
||||
$userIds = array_values(array_filter($userIds, static fn ($id) => $id > 0));
|
||||
if (!$userIds) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
|
||||
$result = DB::update(
|
||||
"update users set authz_version = authz_version + 1 where id in ($placeholders)",
|
||||
...array_map('strval', $userIds)
|
||||
);
|
||||
|
||||
return $result !== false ? (int) $result : 0;
|
||||
}
|
||||
|
||||
public function create(array $data): int|false
|
||||
{
|
||||
$hash = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
return DB::insert(
|
||||
'insert into users (uuid, first_name, last_name, display_name, email, profile_description, job_title, phone, mobile, short_dial, address, postal_code, city, country, region, hire_date, password, locale, totp_secret, theme, primary_tenant_id, current_tenant_id, created_by, created, active, active_changed_at, active_changed_by) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(),?,?,?)',
|
||||
$data['uuid'] ?? RepoQuery::uuidV4(),
|
||||
$data['first_name'],
|
||||
$data['last_name'],
|
||||
$this->buildDisplayName($data),
|
||||
$data['email'],
|
||||
$data['profile_description'] ?? null,
|
||||
$data['job_title'] ?? null,
|
||||
$data['phone'] ?? null,
|
||||
$data['mobile'] ?? null,
|
||||
$data['short_dial'] ?? null,
|
||||
$data['address'] ?? null,
|
||||
$data['postal_code'] ?? null,
|
||||
$data['city'] ?? null,
|
||||
$data['country'] ?? null,
|
||||
$data['region'] ?? null,
|
||||
$data['hire_date'] ?? null,
|
||||
$hash,
|
||||
$data['locale'] ?? null,
|
||||
$data['totp_secret'],
|
||||
$data['theme'] ?? 'light',
|
||||
$data['primary_tenant_id'] ?? null,
|
||||
$data['current_tenant_id'] ?? $data['primary_tenant_id'] ?? null,
|
||||
$data['created_by'] ?? null,
|
||||
$data['active'],
|
||||
$data['active_changed_at'] ?? null,
|
||||
$data['active_changed_by'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
public function update(int $id, array $data): bool
|
||||
{
|
||||
$fields = [
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'display_name' => $this->buildDisplayName($data),
|
||||
'email' => $data['email'],
|
||||
'profile_description' => $data['profile_description'] ?? null,
|
||||
'job_title' => $data['job_title'] ?? null,
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'mobile' => $data['mobile'] ?? null,
|
||||
'short_dial' => $data['short_dial'] ?? null,
|
||||
'address' => $data['address'] ?? null,
|
||||
'postal_code' => $data['postal_code'] ?? null,
|
||||
'city' => $data['city'] ?? null,
|
||||
'country' => $data['country'] ?? null,
|
||||
'region' => $data['region'] ?? null,
|
||||
'hire_date' => $data['hire_date'] ?? null,
|
||||
'totp_secret' => $data['totp_secret'],
|
||||
'active' => $data['active'],
|
||||
];
|
||||
if (array_key_exists('locale', $data)) {
|
||||
$fields['locale'] = $data['locale'];
|
||||
}
|
||||
if (array_key_exists('theme', $data)) {
|
||||
$fields['theme'] = $data['theme'];
|
||||
}
|
||||
if (array_key_exists('primary_tenant_id', $data)) {
|
||||
$fields['primary_tenant_id'] = $data['primary_tenant_id'];
|
||||
}
|
||||
if (array_key_exists('current_tenant_id', $data)) {
|
||||
$fields['current_tenant_id'] = $data['current_tenant_id'];
|
||||
}
|
||||
if (array_key_exists('modified_by', $data)) {
|
||||
$fields['modified_by'] = $data['modified_by'];
|
||||
}
|
||||
if (array_key_exists('active_changed_at', $data)) {
|
||||
$fields['active_changed_at'] = $data['active_changed_at'];
|
||||
}
|
||||
if (array_key_exists('active_changed_by', $data)) {
|
||||
$fields['active_changed_by'] = $data['active_changed_by'];
|
||||
}
|
||||
|
||||
if (!empty($data['password'])) {
|
||||
$fields['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($fields as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$query = 'update users set ' . implode(', ', $setParts) . ' where id = ?';
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function setActive(int $id, bool $active, ?int $changedBy = null): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update users set active = ?, active_changed_at = NOW(), active_changed_by = ? where id = ?',
|
||||
$active ? 1 : 0,
|
||||
$changedBy,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function setCurrentTenant(int $id, int $tenantId): bool
|
||||
{
|
||||
$result = DB::update(
|
||||
'update users set current_tenant_id = ? where id = ?',
|
||||
(string) $tenantId,
|
||||
(string) $id
|
||||
);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function setActiveByUuids(array $uuids, bool $active, ?int $modifiedBy = null): bool
|
||||
{
|
||||
$uuids = array_values(array_filter(array_map('trim', $uuids)));
|
||||
if (!$uuids) {
|
||||
return false;
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($uuids), '?'));
|
||||
$query = "update users set active = ?, modified_by = ?, active_changed_at = NOW(), active_changed_by = ? where uuid in ($placeholders)";
|
||||
$params = array_merge([$active ? 1 : 0, $modifiedBy, $modifiedBy], $uuids);
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function setInactiveByIds(array $userIds, ?int $changedBy = null): int
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $userIds), static fn ($id) => $id > 0)));
|
||||
if (!$ids) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$query = "update users set active = 0, modified_by = ?, active_changed_at = UTC_TIMESTAMP(), active_changed_by = ? where active = 1 and id in ($placeholders)";
|
||||
$params = array_merge([$changedBy, $changedBy], array_map('strval', $ids));
|
||||
$result = DB::update($query, ...$params);
|
||||
return $result !== false ? (int) $result : 0;
|
||||
}
|
||||
|
||||
public function deleteByIds(array $userIds): int
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $userIds), static fn ($id) => $id > 0)));
|
||||
if (!$ids) {
|
||||
return 0;
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$result = DB::delete(
|
||||
"delete from users where id in ($placeholders)",
|
||||
...array_map('strval', $ids)
|
||||
);
|
||||
return $result !== false ? (int) $result : 0;
|
||||
}
|
||||
|
||||
public function setLocale(int $id, string $locale): bool
|
||||
{
|
||||
$result = DB::update('update users set locale = ? where id = ?', $locale, (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function setTheme(int $id, string $theme): bool
|
||||
{
|
||||
$result = DB::update('update users set theme = ? where id = ?', $theme, (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function updateProfileFieldsFromSso(int $id, array $fields): bool
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$row = DB::selectOne('select first_name, last_name, email, phone, mobile, job_title, display_name from users where id = ? limit 1', (string) $id);
|
||||
$existing = $row['users'] ?? null;
|
||||
if (!is_array($existing)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowed = ['first_name', 'last_name', 'display_name', 'job_title', '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['display_name']) && (isset($updates['first_name']) || isset($updates['last_name']))) {
|
||||
$displayData = [
|
||||
'first_name' => $updates['first_name'] ?? (string) ($existing['first_name'] ?? ''),
|
||||
'last_name' => $updates['last_name'] ?? (string) ($existing['last_name'] ?? ''),
|
||||
'email' => (string) ($existing['email'] ?? ''),
|
||||
];
|
||||
$updates['display_name'] = $this->buildDisplayName($displayData);
|
||||
}
|
||||
|
||||
$setParts = [];
|
||||
$params = [];
|
||||
foreach ($updates as $field => $value) {
|
||||
$setParts[] = sprintf('`%s` = ?', $field);
|
||||
$params[] = $value;
|
||||
}
|
||||
$params[] = (string) $id;
|
||||
|
||||
$result = DB::update(
|
||||
'update users set ' . implode(', ', $setParts) . ' where id = ?',
|
||||
...$params
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function setPassword(int $id, string $password): bool
|
||||
{
|
||||
$hash = password_hash($password, PASSWORD_DEFAULT);
|
||||
$result = DB::update('update users set password = ?, modified_by = NULL where id = ?', $hash, (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function setEmailVerified(int $id): bool
|
||||
{
|
||||
$result = DB::update('update users set email_verified_at = NOW() where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function delete(int $id): bool
|
||||
{
|
||||
$result = DB::delete('delete from users where id = ?', (string) $id);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function deleteByUuids(array $uuids): bool
|
||||
{
|
||||
$uuids = array_values(array_filter(array_map('trim', $uuids)));
|
||||
if (!$uuids) {
|
||||
return false;
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($uuids), '?'));
|
||||
$query = "delete from users where uuid in ($placeholders)";
|
||||
$result = DB::delete($query, ...$uuids);
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
private function buildDisplayName(array $data): string
|
||||
{
|
||||
$first = trim((string) ($data['first_name'] ?? ''));
|
||||
$last = trim((string) ($data['last_name'] ?? ''));
|
||||
$name = trim($first . ' ' . $last);
|
||||
if ($name === '') {
|
||||
$name = trim((string) ($data['email'] ?? ''));
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
41
core/Repository/User/UserWriteRepositoryInterface.php
Normal file
41
core/Repository/User/UserWriteRepositoryInterface.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Repository\User;
|
||||
|
||||
/** Contract for user creation, updates, activation, tenant assignment, and preference changes. */
|
||||
interface UserWriteRepositoryInterface
|
||||
{
|
||||
public function updateLastLogin(int $userId, string $provider = 'local'): void;
|
||||
|
||||
public function bumpAuthzVersion(int $userId): bool;
|
||||
|
||||
public function bumpAuthzVersionByUserIds(array $userIds): int;
|
||||
|
||||
public function create(array $data): int|false;
|
||||
|
||||
public function update(int $id, array $data): bool;
|
||||
|
||||
public function setActive(int $id, bool $active, ?int $changedBy = null): bool;
|
||||
|
||||
public function setCurrentTenant(int $id, int $tenantId): bool;
|
||||
|
||||
public function setActiveByUuids(array $uuids, bool $active, ?int $modifiedBy = null): bool;
|
||||
|
||||
public function setInactiveByIds(array $userIds, ?int $changedBy = null): int;
|
||||
|
||||
public function deleteByIds(array $userIds): int;
|
||||
|
||||
public function setLocale(int $id, string $locale): bool;
|
||||
|
||||
public function setTheme(int $id, string $theme): bool;
|
||||
|
||||
public function updateProfileFieldsFromSso(int $id, array $fields): bool;
|
||||
|
||||
public function setPassword(int $id, string $password): bool;
|
||||
|
||||
public function setEmailVerified(int $id): bool;
|
||||
|
||||
public function delete(int $id): bool;
|
||||
|
||||
public function deleteByUuids(array $uuids): bool;
|
||||
}
|
||||
Reference in New Issue
Block a user