fix(heures) : garde backend anti-suppression sur grille périmée (delete explicite) #36
@@ -68,7 +68,8 @@
|
||||
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
|
||||
- Any real modification resets both `isSiteValid=false` and `isValid=false`
|
||||
- No-op saves preserve existing validations
|
||||
- **Enregistrement = seules les lignes modifiées sont envoyées (anti-écrasement concurrent)** : l'écran Heures / Heures Conducteurs affiche toute la journée, et le bulk-upsert (`WorkHourBulkUpsertProcessor`) traite une **entrée vide comme une suppression**. Pour éviter qu'un admin avec une grille **périmée** ne supprime une ligne saisie entre-temps par un autre utilisateur (ex. `ROLE_SELF` non encore validé → non verrouillé), `handleSave` ne transmet **que les lignes dont l'état courant diffère de l'instantané chargé** (`loadedRows`, capturé dans `hydrateRows` ; comparaison `JSON.stringify(buildEntry(current)) !== buildEntry(original)`). Une ligne intouchée n'est jamais envoyée → jamais supprimée. Vidée volontairement → envoyée vide → supprimée (métier conservé). Symétrique dans `useHoursPage.ts` et `useDriverHoursPage.ts`. **Limite** : pas de verrou optimiste backend — l'édition explicite d'une ligne sur données périmées peut toujours écraser une saisie concurrente sur cette même ligne. Doc : `doc/hours-save-dirty-tracking.md`.
|
||||
- **Enregistrement = seules les lignes modifiées sont envoyées (anti-écrasement concurrent)** : l'écran Heures / Heures Conducteurs affiche toute la journée, et le bulk-upsert (`WorkHourBulkUpsertProcessor`) traite une **entrée vide comme une suppression**. Pour éviter qu'un admin avec une grille **périmée** ne supprime une ligne saisie entre-temps par un autre utilisateur (ex. `ROLE_SELF` non encore validé → non verrouillé), `handleSave` ne transmet **que les lignes dont l'état courant diffère de l'instantané chargé** (`loadedRows`, capturé dans `hydrateRows` ; comparaison `JSON.stringify(buildEntry(current)) !== buildEntry(original)`). Une ligne intouchée n'est jamais envoyée → jamais supprimée. Vidée volontairement → envoyée vide → supprimée (métier conservé). Symétrique dans `useHoursPage.ts` et `useDriverHoursPage.ts`.
|
||||
- **Garde backend : suppression sur intention explicite (`delete: true`)** — la protection front ci-dessus **ne couvre que les sessions chargeant le nouveau bundle**. Un **vieil onglet** ouvert avant déploiement tourne encore sur l'ancien JS (sans dirty-tracking) et envoie toute la grille périmée → **le bug s'est reproduit en prod** (onglet ouvert le matin → ~10 lignes saisies dans la journée supprimées). Donc `WorkHourBulkUpsertProcessor` ne supprime désormais une ligne existante sur entrée vide **que si l'entrée porte `delete: true`** (`$deleteRequested = true === ($entry['delete'] ?? false)`). Sinon : **no-op** (ligne préservée) — aucune grille périmée, quel que soit le client, ne peut plus détruire une saisie concurrente. Le front (à jour) pose `delete: true` sur une ligne **vidée volontairement** via `isEntryEmpty(...)` appliqué après le filtre dirty-tracking (les deux composables). Flag **optionnel** (`WorkHourEntryPayload` front + DTO `WorkHourBulkUpsert` back, défaut `false`). Branche de **création de ligne technique de validation** (toggle quand aucune ligne) inchangée : `null === $existing && (absence || contrat 4h)`. Le processor dépend des **interfaces** `EmployeeScopedRepositoryInterface`/`WorkHourReadRepositoryInterface` (repos concrets `final` non mockables) → testé dans `tests/State/WorkHourBulkUpsertProcessorTest.php`. **Limite résiduelle** : pas de verrou optimiste — l'édition **explicite** d'une ligne sur données périmées peut toujours écraser une saisie concurrente sur cette même ligne (seule la **suppression** est protégée). Doc : `doc/hours-save-dirty-tracking.md`.
|
||||
|
||||
## Overtime Rules
|
||||
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
|
||||
|
||||
@@ -45,10 +45,51 @@ Conséquences :
|
||||
Implémenté symétriquement dans `frontend/composables/useHoursPage.ts` (non-conducteurs) et
|
||||
`frontend/composables/useDriverHoursPage.ts` (conducteurs).
|
||||
|
||||
## Limite connue (hors périmètre de ce correctif)
|
||||
## Garde backend : suppression sur intention explicite (`delete: true`)
|
||||
|
||||
Le suivi des lignes modifiées **ne couvre pas** le cas où l'admin **édite explicitement** une
|
||||
ligne sur des données périmées (il voit la ligne vide, tape une valeur, écrasant une saisie
|
||||
concurrente sur cette même ligne). Ce cas résiduel relèverait d'un **verrou optimiste**
|
||||
(comparaison d'`updatedAt`/version côté backend), non implémenté ici. Le backend n'a aucune
|
||||
détection de conflit concurrent (pas de version, pas d'horodatage comparé).
|
||||
Le suivi front **ne protège que les sessions qui chargent le nouveau bundle**. Un **vieil
|
||||
onglet** ouvert avant le déploiement continue de tourner sur l'ancien JavaScript (sans
|
||||
dirty-tracking) et envoie toute la grille périmée → **le bug s'est reproduit en prod** (un onglet
|
||||
ouvert le matin a supprimé une dizaine de lignes saisies dans la journée par d'autres
|
||||
utilisateurs). La protection front est donc **insuffisante** : il faut une garde **côté
|
||||
backend**, indépendante de la version du client.
|
||||
|
||||
`WorkHourBulkUpsertProcessor` ne supprime désormais une ligne existante sur entrée vide **que si
|
||||
la suppression est explicitement demandée** par le flag `delete: true` sur l'entrée :
|
||||
|
||||
```php
|
||||
$deleteRequested = true === ($entry['delete'] ?? false);
|
||||
if ($existing && $deleteRequested) {
|
||||
// suppression (audit 'delete' + remove)
|
||||
} elseif (null === $existing && ($absence || $is4hContract)) {
|
||||
// création d'une ligne technique (validation d'une journée d'absence / contrat 4h)
|
||||
}
|
||||
// existing && !deleteRequested → NO-OP : la ligne existante est préservée
|
||||
```
|
||||
|
||||
Conséquences :
|
||||
|
||||
- Une entrée vide **sans flag** sur une ligne existante est un **no-op** → une grille périmée
|
||||
(n'importe quel client, vieil onglet inclus) **ne peut plus détruire** une saisie concurrente.
|
||||
- Le front (à jour) pose `delete: true` sur une ligne **vidée volontairement** (entrée vide qui
|
||||
diffère de l'instantané chargé, donc transmise) → la suppression métier reste possible.
|
||||
Helper `isEntryEmpty(...)` dans les deux composables, appliqué après le filtre dirty-tracking.
|
||||
|
||||
Le `delete` est **optionnel** (`WorkHourEntryPayload` front, DTO `WorkHourBulkUpsert` back) et
|
||||
vaut `false` par défaut. Les appels de **création de ligne technique de validation** (toggles de
|
||||
validation quand aucune ligne n'existe) envoient une entrée vide sans flag → inchangés (branche
|
||||
`null === $existing`).
|
||||
|
||||
Tests : `tests/State/WorkHourBulkUpsertProcessorTest.php` (no-op sans flag / suppression avec
|
||||
flag / mise à jour d'une entrée non vide). Le processor dépend des interfaces
|
||||
`EmployeeScopedRepositoryInterface` / `WorkHourReadRepositoryInterface` (repos concrets `final`,
|
||||
non mockables) pour permettre ces tests unitaires.
|
||||
|
||||
## Limite connue résiduelle (hors périmètre)
|
||||
|
||||
Reste le cas où l'admin **édite explicitement** une ligne sur des données périmées (il voit la
|
||||
ligne vide, tape une valeur, écrasant une saisie concurrente sur cette même ligne). Ce cas
|
||||
relèverait d'un **verrou optimiste** (comparaison d'`updatedAt`/version côté backend), non
|
||||
implémenté ici — choix métier (option « suppression explicite » retenue plutôt que verrou
|
||||
optimiste). Le backend n'a aucune détection de conflit concurrent sur l'**édition** (seule la
|
||||
**suppression** est désormais protégée par l'intention explicite).
|
||||
|
||||
@@ -959,6 +959,12 @@ export const useDriverHoursPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Une entrée conducteur est vide quand aucune minute (jour/nuit/atelier) ni repas/nuitée
|
||||
// n'est renseignée. Sert à marquer une suppression explicite (`delete: true`).
|
||||
const isEntryEmpty = (entry: ReturnType<typeof buildEntry>) =>
|
||||
!entry.dayHoursMinutes && !entry.nightHoursMinutes && !entry.workshopHoursMinutes
|
||||
&& !entry.hasBreakfast && !entry.hasLunch && !entry.hasDinner && !entry.hasOvernight
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isSubmitting.value || employees.value.length === 0) return
|
||||
|
||||
@@ -977,7 +983,9 @@ export const useDriverHoursPage = () => {
|
||||
// N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais
|
||||
// transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps.
|
||||
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
|
||||
.map(({ current }) => current)
|
||||
// Une ligne vidée par l'utilisateur porte le flag `delete` : le backend n'autorise la
|
||||
// suppression d'une ligne existante que sur intention explicite (anti-grille périmée).
|
||||
.map(({ current }) => (isEntryEmpty(current) ? { ...current, delete: true } : current))
|
||||
|
||||
if (entries.length === 0) return
|
||||
|
||||
|
||||
@@ -1175,6 +1175,14 @@ export const useHoursPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Une entrée est vide quand aucune plage horaire ni présence n'est renseignée.
|
||||
// Sert à marquer une suppression explicite (`delete: true`) côté bulk-upsert.
|
||||
const isEntryEmpty = (entry: ReturnType<typeof buildEntry>) =>
|
||||
!entry.morningFrom && !entry.morningTo
|
||||
&& !entry.afternoonFrom && !entry.afternoonTo
|
||||
&& !entry.eveningFrom && !entry.eveningTo
|
||||
&& !entry.isPresentMorning && !entry.isPresentAfternoon
|
||||
|
||||
const handleSave = async () => {
|
||||
if (isSubmitting.value || employees.value.length === 0) return
|
||||
|
||||
@@ -1190,7 +1198,10 @@ export const useHoursPage = () => {
|
||||
// N'envoie que les lignes réellement modifiées : une ligne intouchée n'est jamais
|
||||
// transmise, donc jamais supprimée même si un autre utilisateur l'a saisie entre-temps.
|
||||
.filter(({ current, original }) => JSON.stringify(current) !== JSON.stringify(original))
|
||||
.map(({ current }) => current)
|
||||
// Une ligne vidée par l'utilisateur (donc différente de l'instantané chargé) porte le
|
||||
// flag `delete` : le backend n'autorise la suppression d'une ligne existante que sur
|
||||
// intention explicite. Sans ce flag, une grille périmée ne peut rien détruire.
|
||||
.map(({ current }) => (isEntryEmpty(current) ? { ...current, delete: true } : current))
|
||||
|
||||
if (entries.length === 0) {
|
||||
return
|
||||
|
||||
@@ -363,6 +363,7 @@ export const documentationSections: DocSection[] = [
|
||||
{ type: 'paragraph', content: 'Les validations sont automatiquement réinitialisées dans certaines conditions.' },
|
||||
{ type: 'list', content: 'Toute vraie modification d\'une ligne remet les deux validations (site et RH) à faux\nUn enregistrement sans changement réel préserve les validations existantes\nLa date de modification est mise à jour uniquement quand un employé modifie ses propres heures' },
|
||||
{ type: 'note', content: 'La date de modification est visible uniquement par les administrateurs, sous le nom de l\'employé dans la vue jour.' },
|
||||
{ type: 'note', content: 'Sécurité anti-écrasement : « Enregistrer » ne touche que les lignes que vous avez réellement modifiées ; les lignes auxquelles vous n\'avez pas touché ne sont jamais envoyées, donc jamais écrasées même si un autre utilisateur les a saisies pendant que votre écran était ouvert. Pour supprimer les heures d\'un salarié, videz explicitement sa ligne puis Enregistrer. Conseil : si votre écran est resté ouvert longtemps, rechargez la page avant de saisir pour repartir des données à jour.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -42,6 +42,10 @@ export type WorkHourEntryPayload = {
|
||||
hasLunch?: boolean
|
||||
hasDinner?: boolean
|
||||
hasOvernight?: boolean
|
||||
// Autorise la suppression d'une ligne existante quand l'entrée est vide.
|
||||
// Sans ce flag, le backend ignore une entrée vide sur une ligne existante
|
||||
// (garde anti-perte de données contre les grilles périmées).
|
||||
delete?: boolean
|
||||
}
|
||||
|
||||
export type WeeklyWorkHourDailySummary = {
|
||||
|
||||
@@ -37,8 +37,13 @@ final class WorkHourBulkUpsert
|
||||
* nightHoursMinutes?:?int,
|
||||
* hasBreakfast?:bool,
|
||||
* hasLunch?:bool,
|
||||
* hasOvernight?:bool
|
||||
* hasOvernight?:bool,
|
||||
* delete?:bool
|
||||
* }>
|
||||
*
|
||||
* Le flag `delete` (défaut false) autorise la suppression d'une ligne existante
|
||||
* quand l'entrée est vide. Sans lui, une entrée vide sur une ligne existante est
|
||||
* un no-op (garde anti-perte de données contre les grilles périmées).
|
||||
*/
|
||||
public array $entries = [];
|
||||
}
|
||||
|
||||
@@ -13,4 +13,11 @@ interface EmployeeScopedRepositoryInterface
|
||||
* @return list<Employee>
|
||||
*/
|
||||
public function findScoped(User $user): array;
|
||||
|
||||
/**
|
||||
* @param list<int> $employeeIds
|
||||
*
|
||||
* @return array<int, Employee>
|
||||
*/
|
||||
public function findAccessibleByIds(array $employeeIds, User $user): array;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ interface WorkHourReadRepositoryInterface
|
||||
*/
|
||||
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
|
||||
|
||||
/**
|
||||
* @param list<Employee> $employees
|
||||
*
|
||||
* @return array<int, WorkHour>
|
||||
*/
|
||||
public function findByDateAndEmployeesIndexedByEmployeeId(DateTimeImmutable $workDate, array $employees): array;
|
||||
|
||||
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour;
|
||||
|
||||
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
|
||||
|
||||
@@ -12,8 +12,8 @@ use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use DateTimeImmutable;
|
||||
@@ -28,8 +28,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private WorkHourRepository $workHourRepository,
|
||||
private EmployeeScopedRepositoryInterface $employeeRepository,
|
||||
private WorkHourReadRepositoryInterface $workHourRepository,
|
||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
private AuditLogger $auditLogger,
|
||||
@@ -142,8 +142,14 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
||||
|
||||
if ($this->isEntryEmpty($normalized)) {
|
||||
// Convention choisie: une ligne vide supprime l'enregistrement existant.
|
||||
if ($existing) {
|
||||
// Garde anti-perte de données : une ligne vide ne supprime l'enregistrement
|
||||
// existant QUE si la suppression est explicitement demandée (`delete: true`).
|
||||
// Sans ce flag, une grille périmée (ex. vieil onglet sans dirty-tracking front)
|
||||
// ne peut plus détruire une ligne saisie entre-temps par un autre utilisateur :
|
||||
// l'entrée vide est traitée comme un no-op.
|
||||
$deleteRequested = true === ($entry['delete'] ?? false);
|
||||
|
||||
if ($existing && $deleteRequested) {
|
||||
$this->auditLogger->log(
|
||||
$employee,
|
||||
'delete',
|
||||
@@ -155,7 +161,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
);
|
||||
$this->entityManager->remove($existing);
|
||||
++$result->deleted;
|
||||
} elseif (($absenceByEmployeeId[$employeeId] ?? false) === true || $is4hContract) {
|
||||
} elseif (null === $existing && (($absenceByEmployeeId[$employeeId] ?? false) === true || $is4hContract)) {
|
||||
// Si une absence existe ce jour ou contrat 4h, on garde une ligne technique pour pouvoir valider la journée.
|
||||
$workHour = new WorkHour()
|
||||
->setEmployee($employee)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\State;
|
||||
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\ApiResource\WorkHourBulkUpsert;
|
||||
use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\State\WorkHourBulkUpsertProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* Garde anti-perte de données : une entrée vide ne supprime une ligne existante
|
||||
* QUE si la suppression est explicitement demandée (flag `delete: true`).
|
||||
* Sans ce flag, une grille périmée (vieil onglet) ne peut plus rien détruire.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class WorkHourBulkUpsertProcessorTest extends TestCase
|
||||
{
|
||||
public function testEmptyEntryWithoutDeleteFlagPreservesExistingRow(): void
|
||||
{
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->expects(self::never())->method('remove');
|
||||
$em->expects(self::once())->method('flush');
|
||||
|
||||
$result = $this->buildProcessor($em)->process(
|
||||
$this->payload(['employeeId' => 7]),
|
||||
new Post(),
|
||||
);
|
||||
|
||||
self::assertSame(0, $result->deleted);
|
||||
self::assertSame(1, $result->processed);
|
||||
}
|
||||
|
||||
public function testEmptyEntryWithDeleteFlagRemovesExistingRow(): void
|
||||
{
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->expects(self::once())->method('remove');
|
||||
$em->expects(self::once())->method('flush');
|
||||
|
||||
$result = $this->buildProcessor($em)->process(
|
||||
$this->payload(['employeeId' => 7, 'delete' => true]),
|
||||
new Post(),
|
||||
);
|
||||
|
||||
self::assertSame(1, $result->deleted);
|
||||
}
|
||||
|
||||
public function testNonEmptyEntryStillUpdatesRegardlessOfFlag(): void
|
||||
{
|
||||
$em = $this->createMock(EntityManagerInterface::class);
|
||||
$em->expects(self::never())->method('remove');
|
||||
$em->expects(self::once())->method('flush');
|
||||
|
||||
$result = $this->buildProcessor($em)->process(
|
||||
$this->payload([
|
||||
'employeeId' => 7,
|
||||
'morningFrom' => '08:00',
|
||||
'morningTo' => '12:30',
|
||||
'afternoonFrom' => '14:00',
|
||||
'afternoonTo' => '18:00',
|
||||
]),
|
||||
new Post(),
|
||||
);
|
||||
|
||||
self::assertSame(1, $result->updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*/
|
||||
private function payload(array $entry): WorkHourBulkUpsert
|
||||
{
|
||||
$payload = new WorkHourBulkUpsert();
|
||||
$payload->workDate = '2026-06-24';
|
||||
$payload->entries = [$entry];
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function buildProcessor(EntityManagerInterface $em): WorkHourBulkUpsertProcessor
|
||||
{
|
||||
$user = new User()->setUsername('Elodie')->setRoles(['ROLE_ADMIN']);
|
||||
|
||||
$employee = new Employee()->setFirstName('Delphine')->setLastName('BACHELIER');
|
||||
|
||||
// Ligne existante NON vide (journée complète saisie entre-temps par un autre utilisateur).
|
||||
$existing = new WorkHour()
|
||||
->setEmployee($employee)
|
||||
->setWorkDate(new DateTimeImmutable('2026-06-24'))
|
||||
->setMorningFrom('08:00')
|
||||
->setMorningTo('12:00')
|
||||
->setAfternoonFrom('14:00')
|
||||
->setAfternoonTo('18:00')
|
||||
;
|
||||
|
||||
$contract = new Contract()->setTrackingMode(TrackingMode::TIME)->setWeeklyHours(35);
|
||||
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('getUser')->willReturn($user);
|
||||
|
||||
$employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class);
|
||||
$employeeRepository->method('findAccessibleByIds')->willReturn([7 => $employee]);
|
||||
|
||||
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
|
||||
$workHourRepository->method('findByDateAndEmployeesIndexedByEmployeeId')->willReturn([7 => $existing]);
|
||||
|
||||
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
||||
$absenceRepository->method('findByDateAndEmployees')->willReturn([]);
|
||||
|
||||
$contractResolver = $this->createStub(EmployeeContractResolver::class);
|
||||
$contractResolver->method('resolveForEmployeeAndDate')->willReturn($contract);
|
||||
$contractResolver->method('resolveIsDriverForEmployeeAndDate')->willReturn(false);
|
||||
|
||||
return new WorkHourBulkUpsertProcessor(
|
||||
$em,
|
||||
$security,
|
||||
$employeeRepository,
|
||||
$workHourRepository,
|
||||
$absenceRepository,
|
||||
$contractResolver,
|
||||
$this->createStub(AuditLogger::class),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user