feat(audit) : contexte forensique dans le journal d'activité (IP, appareil, device id) (#33)
Auto Tag Develop / tag (push) Successful in 9s
Auto Tag Develop / tag (push) Successful in 9s
## Contexte Certains comptes sont **partagés** par plusieurs personnes (ex. compte « Usine »), y compris depuis des smartphones. Le journal d'activité ne stockait que le `username` → impossible de distinguer les intervenants. Cette PR ajoute un **contexte forensique automatique** à chaque entrée du journal. ## Ce qui est ajouté (capté automatiquement, sans friction utilisateur) - **Adresse IP** de la requête - **User-Agent brut** (borné à 1024 caractères) - **Libellé appareil lisible** dérivé du User-Agent : `Type · OS · Navigateur` (ex. `Mobile · Android · Chrome`) - **Identifiant d'appareil persistant** envoyé par le front (header `X-Device-Id`, stocké en `localStorage`, borné à 64 car.) — distingue les **appareils** derrière un compte partagé ## Implémentation - `UserAgentParser` (service maison, sans dépendance) — détection ordonnée OS/navigateur, testée - 4 colonnes **nullable** sur `audit_logs` + migration réversible (pas de backfill, rétro-compatible) - Capture **centralisée** dans `AuditLogger::log()` via `RequestStack` — aucun processor modifié - Champs exposés dans l'API lecture (`AuditLogProvider` + DTO TS aligné) via `AuditLogReadRepositoryInterface` (suit le pattern existant des autres read-repos) - Front : `useDeviceId` + injection du header `X-Device-Id` dans `useApi` (sur toutes les requêtes, SSR-safe) - `framework.trusted_proxies` documenté (commenté) pour une IP correcte derrière un reverse proxy - Docs : `doc/audit-logging.md` + `CLAUDE.md` ## Hors périmètre (étapes suivantes) - **Écran du journal (`audit-logs.vue`) non modifié** — l'affichage des nouvelles colonnes fera l'objet d'une refonte séparée. Les données sont prêtes côté API. - La doc in-app (`documentation-content.ts`) n'est pas touchée : le journal est un outil caché `ROLE_SUPER_ADMIN` sans article existant ni niveau de doc super-admin. ## À noter pour le déploiement - L'IP n'est fiable derrière un reverse proxy qu'une fois `framework.trusted_proxies` activé (livré commenté). ## Tests `OK (249 tests, 533 assertions)` — sortie PHPUnit propre (aucune notice). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #33 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #33.
This commit is contained in:
@@ -16,9 +16,16 @@ use App\State\AuditLogProvider;
|
||||
provider: AuditLogProvider::class,
|
||||
parameters: [
|
||||
new QueryParameter(key: 'employeeId'),
|
||||
new QueryParameter(key: 'employee'),
|
||||
new QueryParameter(key: 'from'),
|
||||
new QueryParameter(key: 'to'),
|
||||
new QueryParameter(key: 'entityType'),
|
||||
new QueryParameter(key: 'action'),
|
||||
new QueryParameter(key: 'username'),
|
||||
new QueryParameter(key: 'ip'),
|
||||
new QueryParameter(key: 'device'),
|
||||
new QueryParameter(key: 'page'),
|
||||
new QueryParameter(key: 'perPage'),
|
||||
],
|
||||
security: "is_granted('ROLE_SUPER_ADMIN')"
|
||||
),
|
||||
|
||||
@@ -46,6 +46,18 @@ class AuditLog
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
private ?DateTimeImmutable $affectedDate = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 45, nullable: true)]
|
||||
private ?string $ipAddress = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $userAgent = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $deviceLabel = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||
private ?string $deviceId = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
@@ -155,6 +167,54 @@ class AuditLog
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIpAddress(): ?string
|
||||
{
|
||||
return $this->ipAddress;
|
||||
}
|
||||
|
||||
public function setIpAddress(?string $ipAddress): self
|
||||
{
|
||||
$this->ipAddress = $ipAddress;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUserAgent(): ?string
|
||||
{
|
||||
return $this->userAgent;
|
||||
}
|
||||
|
||||
public function setUserAgent(?string $userAgent): self
|
||||
{
|
||||
$this->userAgent = $userAgent;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeviceLabel(): ?string
|
||||
{
|
||||
return $this->deviceLabel;
|
||||
}
|
||||
|
||||
public function setDeviceLabel(?string $deviceLabel): self
|
||||
{
|
||||
$this->deviceLabel = $deviceLabel;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDeviceId(): ?string
|
||||
{
|
||||
return $this->deviceId;
|
||||
}
|
||||
|
||||
public function setDeviceId(?string $deviceId): self
|
||||
{
|
||||
$this->deviceId = $deviceId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
|
||||
@@ -5,28 +5,32 @@ declare(strict_types=1);
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Repository\Contract\AuditLogReadRepositoryInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<AuditLog>
|
||||
*/
|
||||
final class AuditLogRepository extends ServiceEntityRepository
|
||||
final class AuditLogRepository extends ServiceEntityRepository implements AuditLogReadRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, AuditLog::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<AuditLog>
|
||||
*/
|
||||
public function findByFilters(
|
||||
?int $employeeId = null,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
?string $entityType = null,
|
||||
?array $entityTypes = null,
|
||||
?array $actions = null,
|
||||
?string $username = null,
|
||||
?string $ip = null,
|
||||
?string $device = null,
|
||||
?string $employeeName = null,
|
||||
int $limit = 50,
|
||||
int $offset = 0,
|
||||
): array {
|
||||
@@ -35,30 +39,7 @@ final class AuditLogRepository extends ServiceEntityRepository
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset)
|
||||
;
|
||||
|
||||
if (null !== $employeeId) {
|
||||
$qb->andWhere('a.employee = :employeeId')
|
||||
->setParameter('employeeId', $employeeId)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $from) {
|
||||
$qb->andWhere('a.affectedDate >= :from')
|
||||
->setParameter('from', $from)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $to) {
|
||||
$qb->andWhere('a.affectedDate <= :to')
|
||||
->setParameter('to', $to)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $entityType) {
|
||||
$qb->andWhere('a.entityType = :entityType')
|
||||
->setParameter('entityType', $entityType)
|
||||
;
|
||||
}
|
||||
$this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device, $employeeName);
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
@@ -67,36 +48,66 @@ final class AuditLogRepository extends ServiceEntityRepository
|
||||
?int $employeeId = null,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
?string $entityType = null,
|
||||
?array $entityTypes = null,
|
||||
?array $actions = null,
|
||||
?string $username = null,
|
||||
?string $ip = null,
|
||||
?string $device = null,
|
||||
?string $employeeName = null,
|
||||
): int {
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->select('COUNT(a.id)')
|
||||
;
|
||||
|
||||
if (null !== $employeeId) {
|
||||
$qb->andWhere('a.employee = :employeeId')
|
||||
->setParameter('employeeId', $employeeId)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $from) {
|
||||
$qb->andWhere('a.affectedDate >= :from')
|
||||
->setParameter('from', $from)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $to) {
|
||||
$qb->andWhere('a.affectedDate <= :to')
|
||||
->setParameter('to', $to)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $entityType) {
|
||||
$qb->andWhere('a.entityType = :entityType')
|
||||
->setParameter('entityType', $entityType)
|
||||
;
|
||||
}
|
||||
$qb = $this->createQueryBuilder('a')->select('COUNT(a.id)');
|
||||
$this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device, $employeeName);
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|list<string> $entityTypes
|
||||
* @param null|list<string> $actions
|
||||
*/
|
||||
private function applyFilters(
|
||||
QueryBuilder $qb,
|
||||
?int $employeeId,
|
||||
?DateTimeImmutable $from,
|
||||
?DateTimeImmutable $to,
|
||||
?array $entityTypes,
|
||||
?array $actions,
|
||||
?string $username,
|
||||
?string $ip,
|
||||
?string $device,
|
||||
?string $employeeName = null,
|
||||
): void {
|
||||
if (null !== $employeeId) {
|
||||
$qb->andWhere('a.employee = :employeeId')->setParameter('employeeId', $employeeId);
|
||||
}
|
||||
if (null !== $employeeName && '' !== $employeeName) {
|
||||
$qb->join('a.employee', 'e')
|
||||
->andWhere('LOWER(e.lastName) LIKE :employeeName OR LOWER(e.firstName) LIKE :employeeName')
|
||||
->setParameter('employeeName', '%'.mb_strtolower($employeeName).'%')
|
||||
;
|
||||
}
|
||||
if (null !== $from) {
|
||||
$qb->andWhere('a.affectedDate >= :from')->setParameter('from', $from);
|
||||
}
|
||||
if (null !== $to) {
|
||||
$qb->andWhere('a.affectedDate <= :to')->setParameter('to', $to);
|
||||
}
|
||||
if (null !== $entityTypes && [] !== $entityTypes) {
|
||||
$qb->andWhere('a.entityType IN (:entityTypes)')->setParameter('entityTypes', $entityTypes);
|
||||
}
|
||||
if (null !== $actions && [] !== $actions) {
|
||||
$qb->andWhere('a.action IN (:actions)')->setParameter('actions', $actions);
|
||||
}
|
||||
if (null !== $username && '' !== $username) {
|
||||
$qb->andWhere('LOWER(a.username) LIKE :username')->setParameter('username', '%'.mb_strtolower($username).'%');
|
||||
}
|
||||
if (null !== $ip && '' !== $ip) {
|
||||
$qb->andWhere('LOWER(a.ipAddress) LIKE :ip')->setParameter('ip', '%'.mb_strtolower($ip).'%');
|
||||
}
|
||||
if (null !== $device && '' !== $device) {
|
||||
$qb->andWhere('(LOWER(a.deviceLabel) LIKE :device OR LOWER(a.deviceId) LIKE :device)')
|
||||
->setParameter('device', '%'.mb_strtolower($device).'%')
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository\Contract;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use DateTimeImmutable;
|
||||
|
||||
interface AuditLogReadRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @param null|list<string> $entityTypes
|
||||
* @param null|list<string> $actions
|
||||
*
|
||||
* @return list<AuditLog>
|
||||
*/
|
||||
public function findByFilters(
|
||||
?int $employeeId = null,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
?array $entityTypes = null,
|
||||
?array $actions = null,
|
||||
?string $username = null,
|
||||
?string $ip = null,
|
||||
?string $device = null,
|
||||
?string $employeeName = null,
|
||||
int $limit = 50,
|
||||
int $offset = 0,
|
||||
): array;
|
||||
|
||||
/**
|
||||
* @param null|list<string> $entityTypes
|
||||
* @param null|list<string> $actions
|
||||
*/
|
||||
public function countByFilters(
|
||||
?int $employeeId = null,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
?array $entityTypes = null,
|
||||
?array $actions = null,
|
||||
?string $username = null,
|
||||
?string $ip = null,
|
||||
?string $device = null,
|
||||
?string $employeeName = null,
|
||||
): int;
|
||||
}
|
||||
@@ -10,12 +10,15 @@ use App\Entity\User;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
readonly class AuditLogger
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private RequestStack $requestStack,
|
||||
private UserAgentParser $userAgentParser,
|
||||
) {}
|
||||
|
||||
public function log(
|
||||
@@ -30,6 +33,25 @@ readonly class AuditLogger
|
||||
$user = $this->security->getUser();
|
||||
$username = $user instanceof User ? $user->getUsername() : 'system';
|
||||
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$ipAddress = null;
|
||||
$userAgent = null;
|
||||
$deviceId = null;
|
||||
|
||||
if (null !== $request) {
|
||||
$ipAddress = $request->getClientIp();
|
||||
$userAgent = $request->headers->get('User-Agent');
|
||||
$deviceId = $request->headers->get('X-Device-Id');
|
||||
// The device id comes from an untrusted client header; cap it to the column width.
|
||||
if (null !== $deviceId) {
|
||||
$deviceId = mb_substr($deviceId, 0, 64);
|
||||
}
|
||||
// The user agent comes from an untrusted client header; cap it to prevent storage bloat.
|
||||
if (null !== $userAgent) {
|
||||
$userAgent = mb_substr($userAgent, 0, 1024);
|
||||
}
|
||||
}
|
||||
|
||||
$auditLog = new AuditLog();
|
||||
$auditLog
|
||||
->setEmployee($employee)
|
||||
@@ -40,6 +62,10 @@ readonly class AuditLogger
|
||||
->setDescription($description)
|
||||
->setChanges($changes)
|
||||
->setAffectedDate($affectedDate)
|
||||
->setIpAddress($ipAddress)
|
||||
->setUserAgent($userAgent)
|
||||
->setDeviceLabel($this->userAgentParser->parse($userAgent))
|
||||
->setDeviceId($deviceId)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($auditLog);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
/**
|
||||
* Derives a short, human-readable label ("Type · OS · Browser") from a raw
|
||||
* User-Agent string, used to add forensic context to audit log entries.
|
||||
* Heuristic on purpose — enough to tell a phone from a desktop and identify
|
||||
* OS/browser families on shared accounts.
|
||||
*/
|
||||
class UserAgentParser
|
||||
{
|
||||
public function parse(?string $userAgent): ?string
|
||||
{
|
||||
if (null === $userAgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ua = trim($userAgent);
|
||||
if ('' === $ua) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode(' · ', [
|
||||
$this->detectType($ua),
|
||||
$this->detectOs($ua),
|
||||
$this->detectBrowser($ua),
|
||||
]);
|
||||
}
|
||||
|
||||
private function detectType(string $ua): string
|
||||
{
|
||||
if (1 === preg_match('/iPad|Tablet/i', $ua)) {
|
||||
return 'Tablette';
|
||||
}
|
||||
|
||||
if (1 === preg_match('/Mobile|Android|iPhone|iPod/i', $ua)) {
|
||||
return 'Mobile';
|
||||
}
|
||||
|
||||
return 'Ordinateur';
|
||||
}
|
||||
|
||||
private function detectOs(string $ua): string
|
||||
{
|
||||
// Order matters: iOS before macOS (iOS UAs contain "Mac OS X"),
|
||||
// Android before Linux (Android UAs contain "Linux").
|
||||
return match (true) {
|
||||
1 === preg_match('/iPhone|iPad|iPod/i', $ua) => 'iOS',
|
||||
1 === preg_match('/Android/i', $ua) => 'Android',
|
||||
1 === preg_match('/Windows/i', $ua) => 'Windows',
|
||||
1 === preg_match('/Mac OS X|Macintosh/i', $ua) => 'macOS',
|
||||
1 === preg_match('/Linux/i', $ua) => 'Linux',
|
||||
default => 'Autre',
|
||||
};
|
||||
}
|
||||
|
||||
private function detectBrowser(string $ua): string
|
||||
{
|
||||
// Order matters: Edge/Opera contain "Chrome" and "Safari";
|
||||
// Chrome contains "Safari". Match the most specific first.
|
||||
return match (true) {
|
||||
1 === preg_match('/Edg/i', $ua) => 'Edge',
|
||||
1 === preg_match('/OPR|Opera/i', $ua) => 'Opera',
|
||||
1 === preg_match('/Firefox|FxiOS/i', $ua) => 'Firefox',
|
||||
1 === preg_match('/Chrome|CriOS/i', $ua) => 'Chrome',
|
||||
1 === preg_match('/Safari/i', $ua) => 'Safari',
|
||||
default => 'Autre',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\Contract\AuditLogReadRepositoryInterface;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
@@ -14,11 +14,12 @@ use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
class AuditLogProvider implements ProviderInterface
|
||||
{
|
||||
private const PER_PAGE = 50;
|
||||
private const DEFAULT_PER_PAGE = 10;
|
||||
private const ALLOWED_PER_PAGE = [10, 25, 50, 100];
|
||||
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly AuditLogRepository $auditLogRepository,
|
||||
private readonly AuditLogReadRepositoryInterface $auditLogRepository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
|
||||
@@ -28,20 +29,33 @@ class AuditLogProvider implements ProviderInterface
|
||||
return new JsonResponse(['items' => [], 'total' => 0]);
|
||||
}
|
||||
|
||||
$employeeId = $request->query->get('employeeId');
|
||||
$from = $request->query->get('from');
|
||||
$to = $request->query->get('to');
|
||||
$entityType = $request->query->get('entityType');
|
||||
$page = max(1, (int) $request->query->get('page', '1'));
|
||||
$query = $request->query;
|
||||
$all = $query->all();
|
||||
|
||||
$employeeId = $query->get('employeeId');
|
||||
$from = $query->get('from');
|
||||
$to = $query->get('to');
|
||||
$page = max(1, (int) $query->get('page', '1'));
|
||||
|
||||
$perPage = (int) $query->get('perPage', (string) self::DEFAULT_PER_PAGE);
|
||||
if (!in_array($perPage, self::ALLOWED_PER_PAGE, true)) {
|
||||
$perPage = self::DEFAULT_PER_PAGE;
|
||||
}
|
||||
|
||||
$entityTypes = $this->normalizeList($all['entityType'] ?? null);
|
||||
$actions = $this->normalizeList($all['action'] ?? null);
|
||||
$username = $this->normalizeString($query->get('username'));
|
||||
$ip = $this->normalizeString($query->get('ip'));
|
||||
$device = $this->normalizeString($query->get('device'));
|
||||
$employee = $this->normalizeString($query->get('employee'));
|
||||
|
||||
$empId = $employeeId ? (int) $employeeId : null;
|
||||
$fromDt = $from ? new DateTimeImmutable($from) : null;
|
||||
$toDt = $to ? new DateTimeImmutable($to) : null;
|
||||
$type = $entityType ?: null;
|
||||
$offset = ($page - 1) * self::PER_PAGE;
|
||||
$fromDt = $from ? new DateTimeImmutable((string) $from) : null;
|
||||
$toDt = $to ? new DateTimeImmutable((string) $to) : null;
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $type);
|
||||
$logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $type, self::PER_PAGE, $offset);
|
||||
$total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device, $employee);
|
||||
$logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device, $employee, $perPage, $offset);
|
||||
|
||||
$items = [];
|
||||
foreach ($logs as $log) {
|
||||
@@ -60,6 +74,10 @@ class AuditLogProvider implements ProviderInterface
|
||||
'description' => $log->getDescription(),
|
||||
'changes' => $log->getChanges(),
|
||||
'affectedDate' => $log->getAffectedDate()?->format('Y-m-d'),
|
||||
'ipAddress' => $log->getIpAddress(),
|
||||
'userAgent' => $log->getUserAgent(),
|
||||
'deviceLabel' => $log->getDeviceLabel(),
|
||||
'deviceId' => $log->getDeviceId(),
|
||||
'createdAt' => $log->getCreatedAt()->setTimezone(new DateTimeZone('Europe/Paris'))->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
@@ -68,7 +86,27 @@ class AuditLogProvider implements ProviderInterface
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'perPage' => self::PER_PAGE,
|
||||
'perPage' => $perPage,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|list<string>
|
||||
*/
|
||||
private function normalizeList(mixed $value): ?array
|
||||
{
|
||||
$list = array_values(array_filter(
|
||||
(array) ($value ?? []),
|
||||
static fn ($v): bool => is_string($v) && '' !== trim($v),
|
||||
));
|
||||
|
||||
return [] === $list ? null : $list;
|
||||
}
|
||||
|
||||
private function normalizeString(mixed $value): ?string
|
||||
{
|
||||
$trimmed = trim((string) ($value ?? ''));
|
||||
|
||||
return '' === $trimmed ? null : $trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user