feat: Permettre aux administrateurs de configurer les règles de devoirs
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

Les établissements ont besoin de protéger les élèves et familles des
devoirs de dernière minute. Cette configuration au niveau tenant permet
de définir des règles de timing (délai minimum, pas de devoir pour
lundi après une heure limite) et un mode d'application (avertissement,
blocage ou désactivé).

Le service de validation est prêt pour être branché dans le flux de
création de devoirs (Stories 5.4/5.5). L'historique des changements
assure la traçabilité des modifications de configuration.
This commit is contained in:
2026-03-17 21:27:06 +01:00
parent a708af3a8f
commit 5f3c5c2d71
39 changed files with 4007 additions and 2 deletions

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UpdateHomeworkRules;
/**
* Commande pour mettre à jour les règles de devoirs d'un établissement.
*
* @see FR81: Configurer règles de timing des devoirs
*/
final readonly class UpdateHomeworkRulesCommand
{
/**
* @param array<int, array{type: string, params: array<string, mixed>}> $rules
*/
public function __construct(
public string $tenantId,
public array $rules,
public string $enforcementMode,
public bool $enabled,
public string $changedBy,
) {
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UpdateHomeworkRules;
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\HomeworkRulesHistoryRepository;
use App\Administration\Domain\Repository\HomeworkRulesRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function count;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Orchestre la mise à jour des règles de devoirs.
*
* Crée la configuration si elle n'existe pas encore pour le tenant.
* Enregistre l'historique des changements pour l'audit (AC6).
*
* L'historique est conditionné sur un changement effectif (comparaison avant/après).
* Les domain events ne sont PAS consommés ici — ils restent disponibles pour
* le Processor qui les dispatche sur l'event bus.
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class UpdateHomeworkRulesHandler
{
public function __construct(
private HomeworkRulesRepository $homeworkRulesRepository,
private HomeworkRulesHistoryRepository $historyRepository,
private Clock $clock,
) {
}
public function __invoke(UpdateHomeworkRulesCommand $command): HomeworkRules
{
$tenantId = TenantId::fromString($command->tenantId);
$enforcementMode = EnforcementMode::from($command->enforcementMode);
$now = $this->clock->now();
$rules = array_map(HomeworkRule::fromArray(...), $command->rules);
$homeworkRules = $this->homeworkRulesRepository->findByTenantId($tenantId);
// Capturer l'état précédent pour l'historique.
// null = première configuration du tenant (aucune règle avant).
$previousRules = $homeworkRules?->rules;
$previousMode = $homeworkRules?->enforcementMode;
$previousEnabled = $homeworkRules?->enabled;
if ($homeworkRules === null) {
$homeworkRules = HomeworkRules::creer(tenantId: $tenantId, now: $now);
}
$homeworkRules->mettreAJour(
rules: $rules,
enforcementMode: $enforcementMode,
enabled: $command->enabled,
now: $now,
);
$this->homeworkRulesRepository->save($homeworkRules);
// Enregistrer l'historique uniquement si quelque chose a changé.
// On compare l'état avant/après au lieu de consommer pullDomainEvents(),
// car le Processor en a besoin pour dispatcher sur l'event bus.
$changed = $previousRules === null
|| $previousMode !== $enforcementMode
|| $previousEnabled !== $command->enabled
|| !$this->rulesEqual($previousRules, $rules);
if ($changed) {
$this->historyRepository->record(
tenantId: $tenantId,
previousRules: $previousRules,
newRules: $rules,
enforcementMode: $enforcementMode,
enabled: $command->enabled,
changedBy: UserId::fromString($command->changedBy),
changedAt: $now,
);
}
return $homeworkRules;
}
/**
* @param HomeworkRule[] $a
* @param HomeworkRule[] $b
*/
private function rulesEqual(array $a, array $b): bool
{
if (count($a) !== count($b)) {
return false;
}
foreach ($a as $i => $rule) {
if (!isset($b[$i]) || !$rule->equals($b[$i])) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service;
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
use function array_map;
use function count;
/**
* Résultat de la validation des règles de devoirs.
*
* Selon le mode d'application, les violations sont des warnings (soft)
* ou des erreurs bloquantes (hard).
*/
final readonly class HomeworkRulesValidationResult
{
/**
* @param RuleViolation[] $violations
*/
public function __construct(
public array $violations,
public EnforcementMode $enforcementMode,
) {
}
public function estValide(): bool
{
return count($this->violations) === 0;
}
public function estBloquant(): bool
{
return !$this->estValide() && $this->enforcementMode->estBloquant();
}
public function estAvertissement(): bool
{
return !$this->estValide() && !$this->enforcementMode->estBloquant();
}
/**
* @return string[]
*/
public function messages(): array
{
return array_map(
static fn (RuleViolation $v): string => $v->message,
$this->violations,
);
}
public static function ok(EnforcementMode $mode): self
{
return new self(violations: [], enforcementMode: $mode);
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
use App\Administration\Domain\Model\HomeworkRules\RuleType;
use DateTimeImmutable;
use LogicException;
use function sprintf;
use function strtolower;
/**
* Valide une date d'échéance de devoir contre les règles configurées.
*
* @see FR81: Configurer règles de timing des devoirs
*/
final readonly class HomeworkRulesValidator
{
/**
* Valide si un devoir peut être créé avec la date d'échéance donnée.
*/
public function valider(
HomeworkRules $rules,
DateTimeImmutable $dueDate,
DateTimeImmutable $creationDate,
): HomeworkRulesValidationResult {
if (!$rules->estActif()) {
return HomeworkRulesValidationResult::ok($rules->enforcementMode);
}
$violations = [];
foreach ($rules->rules as $rule) {
$violation = $this->validerRegle($rule, $dueDate, $creationDate);
if ($violation !== null) {
$violations[] = $violation;
}
}
return new HomeworkRulesValidationResult(
violations: $violations,
enforcementMode: $rules->enforcementMode,
);
}
private function validerRegle(
HomeworkRule $rule,
DateTimeImmutable $dueDate,
DateTimeImmutable $creationDate,
): ?RuleViolation {
return match ($rule->type) {
RuleType::MINIMUM_DELAY => $this->validerDelaiMinimum($rule, $dueDate, $creationDate),
RuleType::NO_MONDAY_AFTER => $this->validerPasLundiApres($rule, $dueDate, $creationDate),
};
}
/**
* AC3 : Configuration "3 jours" → devoir pour vendredi ne peut pas être créé après mardi.
*/
private function validerDelaiMinimum(
HomeworkRule $rule,
DateTimeImmutable $dueDate,
DateTimeImmutable $creationDate,
): ?RuleViolation {
/** @var int|string $rawDays */
$rawDays = $rule->params['days'];
$delayDays = (int) $rawDays;
$deadline = $dueDate->modify(sprintf('-%d days', $delayDays));
$creationDay = $creationDate->format('Y-m-d');
$deadlineDay = $deadline->format('Y-m-d');
if ($creationDay > $deadlineDay) {
return new RuleViolation(
ruleType: RuleType::MINIMUM_DELAY,
message: sprintf(
'Le devoir doit être créé au moins %d jours avant l\'échéance. Date limite de création : %s.',
$delayDays,
$deadline->format('d/m/Y'),
),
);
}
return null;
}
/**
* AC4 : Configuration "après vendredi 12h" → devoir pour lundi ne peut pas être créé après vendredi midi.
*/
private function validerPasLundiApres(
HomeworkRule $rule,
DateTimeImmutable $dueDate,
DateTimeImmutable $creationDate,
): ?RuleViolation {
$dueDayOfWeek = (int) $dueDate->format('N');
// Règle ne s'applique que si le devoir est pour lundi (jour 1)
if ($dueDayOfWeek !== 1) {
return null;
}
/** @var string $rawDay */
$rawDay = $rule->params['day'];
$cutoffDay = strtolower($rawDay);
/** @var string $cutoffTime */
$cutoffTime = $rule->params['time'];
$cutoffDayNumber = $this->dayNameToNumber($cutoffDay);
// Trouver le jour cutoff de la semaine précédente par rapport au lundi
$daysDiff = $dueDayOfWeek - $cutoffDayNumber;
if ($daysDiff <= 0) {
$daysDiff += 7;
}
$cutoffDate = $dueDate->modify(sprintf('-%d days', $daysDiff));
$cutoffDateTime = new DateTimeImmutable(
$cutoffDate->format('Y-m-d') . ' ' . $cutoffTime,
);
if ($creationDate > $cutoffDateTime) {
return new RuleViolation(
ruleType: RuleType::NO_MONDAY_AFTER,
message: sprintf(
'Les devoirs pour lundi ne peuvent pas être créés après %s %s.',
$this->dayLabel($cutoffDay),
$cutoffTime,
),
);
}
return null;
}
private function dayNameToNumber(string $day): int
{
return match ($day) {
'monday' => 1,
'tuesday' => 2,
'wednesday' => 3,
'thursday' => 4,
'friday' => 5,
'saturday' => 6,
'sunday' => 7,
default => throw new LogicException(sprintf('Jour invalide "%s" — HomeworkRule aurait dû rejeter cette valeur.', $day)),
};
}
private function dayLabel(string $day): string
{
return match ($day) {
'monday' => 'lundi',
'tuesday' => 'mardi',
'wednesday' => 'mercredi',
'thursday' => 'jeudi',
'friday' => 'vendredi',
'saturday' => 'samedi',
'sunday' => 'dimanche',
default => throw new LogicException(sprintf('Jour invalide "%s" — HomeworkRule aurait dû rejeter cette valeur.', $day)),
};
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service;
use App\Administration\Domain\Model\HomeworkRules\RuleType;
/**
* Représente une violation d'une règle de devoirs.
*/
final readonly class RuleViolation
{
public function __construct(
public RuleType $ruleType,
public string $message,
) {
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRulesId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lors de la modification des règles de devoirs d'un établissement.
*
* Porte l'état avant/après pour permettre l'historique des changements (AC6).
*/
final readonly class ReglesDevoirsModifiees implements DomainEvent
{
/**
* @param array<int, array{type: string, params: array<string, mixed>}> $previousRules
* @param array<int, array{type: string, params: array<string, mixed>}> $newRules
*/
public function __construct(
public HomeworkRulesId $homeworkRulesId,
public TenantId $tenantId,
public array $previousRules,
public array $newRules,
public EnforcementMode $enforcementMode,
public bool $enabled,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->homeworkRulesId->value;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\HomeworkRules\RuleType;
use DomainException;
use function implode;
use function sprintf;
final class HomeworkRuleParamsInvalidException extends DomainException
{
/**
* @param string[] $missing
*/
public static function missingParams(RuleType $type, array $missing): self
{
return new self(sprintf(
'La règle "%s" requiert les paramètres manquants : %s.',
$type->value,
implode(', ', $missing),
));
}
public static function invalidValue(RuleType $type, string $param, string $reason): self
{
return new self(sprintf(
'La règle "%s", paramètre "%s" : %s.',
$type->value,
$param,
$reason,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Shared\Domain\Tenant\TenantId;
use DomainException;
use function sprintf;
final class HomeworkRulesNotFoundException extends DomainException
{
public static function pourTenant(TenantId $tenantId): self
{
return new self(sprintf(
'Aucune configuration de règles de devoirs trouvée pour le tenant "%s".',
$tenantId,
));
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\HomeworkRules;
/**
* Mode d'application des règles de devoirs.
*
* - SOFT : avertissement uniquement, l'enseignant peut passer outre
* - HARD : blocage, impossible de créer le devoir
* - DISABLED : aucune vérification
*
* @see FR81: Configurer règles de timing des devoirs
*/
enum EnforcementMode: string
{
case SOFT = 'soft';
case HARD = 'hard';
case DISABLED = 'disabled';
/**
* Détermine si les règles doivent être vérifiées dans ce mode.
*/
public function estActif(): bool
{
return $this !== self::DISABLED;
}
/**
* Détermine si les violations doivent bloquer la création du devoir.
*/
public function estBloquant(): bool
{
return $this === self::HARD;
}
/**
* Libellé utilisateur en français.
*/
public function label(): string
{
return match ($this) {
self::SOFT => 'Avertissement',
self::HARD => 'Blocage',
self::DISABLED => 'Désactivé',
};
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\HomeworkRules;
use App\Administration\Domain\Exception\HomeworkRuleParamsInvalidException;
use function array_diff;
use function array_keys;
use function count;
use function in_array;
use function is_int;
use function is_string;
use function preg_match;
/**
* Définition d'une règle individuelle de devoirs.
*
* Value Object immutable contenant le type de règle et ses paramètres.
* Chaque type de règle impose ses propres paramètres requis.
*
* @see FR81: Configurer règles de timing des devoirs
*/
final readonly class HomeworkRule
{
/**
* @param array<string, mixed> $params
*/
public function __construct(
public RuleType $type,
public array $params,
) {
$missing = array_diff($type->requiredParams(), array_keys($params));
if (count($missing) > 0) {
throw HomeworkRuleParamsInvalidException::missingParams($type, $missing);
}
self::validateValues($type, $params);
}
private const array VALID_DAYS = [
'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday',
];
/**
* @param array<string, mixed> $params
*/
private static function validateValues(RuleType $type, array $params): void
{
match ($type) {
RuleType::MINIMUM_DELAY => self::validateMinimumDelay($params),
RuleType::NO_MONDAY_AFTER => self::validateNoMondayAfter($params),
};
}
/**
* @param array<string, mixed> $params
*/
private static function validateMinimumDelay(array $params): void
{
$days = $params['days'];
if (!is_int($days) || $days < 1 || $days > 30) {
throw HomeworkRuleParamsInvalidException::invalidValue(
RuleType::MINIMUM_DELAY,
'days',
'doit être un entier entre 1 et 30',
);
}
}
/**
* @param array<string, mixed> $params
*/
private static function validateNoMondayAfter(array $params): void
{
$day = $params['day'];
if (!is_string($day) || !in_array($day, self::VALID_DAYS, true)) {
throw HomeworkRuleParamsInvalidException::invalidValue(
RuleType::NO_MONDAY_AFTER,
'day',
'doit être un jour valide en anglais (monday, tuesday, ...)',
);
}
$time = $params['time'];
if (!is_string($time) || preg_match('/^\d{2}:\d{2}$/', $time) !== 1) {
throw HomeworkRuleParamsInvalidException::invalidValue(
RuleType::NO_MONDAY_AFTER,
'time',
'doit être au format HH:MM',
);
}
}
public function equals(self $other): bool
{
return $this->type === $other->type && $this->params === $other->params;
}
/**
* @return array{type: string, params: array<string, mixed>}
*/
public function toArray(): array
{
return [
'type' => $this->type->value,
'params' => $this->params,
];
}
/**
* @param array{type: string, params: array<string, mixed>} $data
*/
public static function fromArray(array $data): self
{
return new self(
type: RuleType::from($data['type']),
params: $data['params'],
);
}
}

View File

@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\HomeworkRules;
use App\Administration\Domain\Event\ReglesDevoirsModifiees;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function count;
use DateTimeImmutable;
/**
* Aggregate Root représentant la configuration des règles de devoirs d'un établissement.
*
* Chaque établissement dispose d'une unique configuration de règles (1:1 avec Tenant).
* Les règles protègent les élèves et familles des devoirs de dernière minute.
*
* @see FR81: Configurer règles de timing des devoirs
*/
final class HomeworkRules extends AggregateRoot
{
public private(set) DateTimeImmutable $updatedAt;
/**
* @param HomeworkRule[] $rules
*/
private function __construct(
public private(set) HomeworkRulesId $id,
public private(set) TenantId $tenantId,
public private(set) array $rules,
public private(set) EnforcementMode $enforcementMode,
public private(set) bool $enabled,
public private(set) DateTimeImmutable $createdAt,
) {
$this->updatedAt = $createdAt;
}
/**
* Crée une configuration de règles par défaut pour un établissement.
*
* Par défaut : aucune règle active, mode soft, activé.
*/
public static function creer(
TenantId $tenantId,
DateTimeImmutable $now,
): self {
return new self(
id: HomeworkRulesId::generate(),
tenantId: $tenantId,
rules: [],
enforcementMode: EnforcementMode::SOFT,
enabled: true,
createdAt: $now,
);
}
/**
* Met à jour les règles, le mode d'application et l'état d'activation.
*
* @param HomeworkRule[] $rules
*/
public function mettreAJour(
array $rules,
EnforcementMode $enforcementMode,
bool $enabled,
DateTimeImmutable $now,
): void {
$rulesChanged = !$this->rulesEqual($rules);
$modeChanged = $this->enforcementMode !== $enforcementMode;
$enabledChanged = $this->enabled !== $enabled;
if (!$rulesChanged && !$modeChanged && !$enabledChanged) {
return;
}
$previousRules = $this->rules;
$this->rules = $rules;
$this->enforcementMode = $enforcementMode;
$this->enabled = $enabled;
$this->updatedAt = $now;
$this->recordEvent(new ReglesDevoirsModifiees(
homeworkRulesId: $this->id,
tenantId: $this->tenantId,
previousRules: array_map(
static fn (HomeworkRule $rule): array => $rule->toArray(),
$previousRules,
),
newRules: array_map(
static fn (HomeworkRule $rule): array => $rule->toArray(),
$rules,
),
enforcementMode: $enforcementMode,
enabled: $enabled,
occurredOn: $now,
));
}
/**
* Désactive complètement les règles.
*
* Les enseignants peuvent créer des devoirs librement.
*/
public function desactiver(DateTimeImmutable $now): void
{
$this->mettreAJour(
rules: $this->rules,
enforcementMode: EnforcementMode::DISABLED,
enabled: false,
now: $now,
);
}
/**
* Réactive les règles avec le mode précédemment configuré.
*/
public function activer(EnforcementMode $mode, DateTimeImmutable $now): void
{
$this->mettreAJour(
rules: $this->rules,
enforcementMode: $mode,
enabled: true,
now: $now,
);
}
/**
* Vérifie si les règles sont actives et applicables.
*/
public function estActif(): bool
{
return $this->enabled && $this->enforcementMode->estActif();
}
/**
* @internal Pour usage Infrastructure uniquement
*
* @param HomeworkRule[] $rules
*/
public static function reconstitute(
HomeworkRulesId $id,
TenantId $tenantId,
array $rules,
EnforcementMode $enforcementMode,
bool $enabled,
DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt,
): self {
$homeworkRules = new self(
id: $id,
tenantId: $tenantId,
rules: $rules,
enforcementMode: $enforcementMode,
enabled: $enabled,
createdAt: $createdAt,
);
$homeworkRules->updatedAt = $updatedAt;
return $homeworkRules;
}
/**
* @param HomeworkRule[] $other
*/
private function rulesEqual(array $other): bool
{
if (count($this->rules) !== count($other)) {
return false;
}
foreach ($this->rules as $i => $rule) {
if (!isset($other[$i]) || !$rule->equals($other[$i])) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\HomeworkRules;
use App\Shared\Domain\EntityId;
final readonly class HomeworkRulesId extends EntityId
{
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Model\HomeworkRules;
/**
* Type de règle de devoirs configurable par l'établissement.
*
* Extensible : de nouveaux types pourront être ajoutés (V2)
* sans impacter les règles existantes.
*
* @see FR81: Configurer règles de timing des devoirs
*/
enum RuleType: string
{
case MINIMUM_DELAY = 'minimum_delay';
case NO_MONDAY_AFTER = 'no_monday_after';
/**
* Clés de paramètres attendues pour ce type de règle.
*
* @return string[]
*/
public function requiredParams(): array
{
return match ($this) {
self::MINIMUM_DELAY => ['days'],
self::NO_MONDAY_AFTER => ['day', 'time'],
};
}
/**
* Libellé utilisateur en français.
*/
public function label(): string
{
return match ($this) {
self::MINIMUM_DELAY => 'Délai minimum avant échéance',
self::NO_MONDAY_AFTER => 'Pas de devoir pour lundi après un horaire',
};
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
interface HomeworkRulesHistoryRepository
{
/**
* @param HomeworkRule[]|null $previousRules
* @param HomeworkRule[] $newRules
*/
public function record(
TenantId $tenantId,
?array $previousRules,
array $newRules,
EnforcementMode $enforcementMode,
bool $enabled,
UserId $changedBy,
DateTimeImmutable $changedAt,
): void;
/**
* @return array<array{
* id: string,
* previous_rules: string|null,
* new_rules: string,
* enforcement_mode: string,
* enabled: bool,
* changed_by: string,
* changed_at: string,
* }>
*/
public function findByTenant(TenantId $tenantId, int $limit = 50): array;
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Repository;
use App\Administration\Domain\Exception\HomeworkRulesNotFoundException;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
use App\Shared\Domain\Tenant\TenantId;
interface HomeworkRulesRepository
{
public function save(HomeworkRules $homeworkRules): void;
/** @throws HomeworkRulesNotFoundException */
public function get(TenantId $tenantId): HomeworkRules;
public function findByTenantId(TenantId $tenantId): ?HomeworkRules;
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\UpdateHomeworkRules\UpdateHomeworkRulesCommand;
use App\Administration\Application\Command\UpdateHomeworkRules\UpdateHomeworkRulesHandler;
use App\Administration\Domain\Exception\HomeworkRuleParamsInvalidException;
use App\Administration\Infrastructure\Api\Resource\HomeworkRulesResource;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Messenger\MessageBusInterface;
use ValueError;
/**
* Processor pour la mise à jour des règles de devoirs.
*
* @implements ProcessorInterface<HomeworkRulesResource, HomeworkRulesResource>
*/
final readonly class UpdateHomeworkRulesProcessor implements ProcessorInterface
{
public function __construct(
private UpdateHomeworkRulesHandler $handler,
private TenantContext $tenantContext,
private MessageBusInterface $eventBus,
private Security $security,
) {
}
/**
* @param HomeworkRulesResource $data
*/
#[Override]
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): HomeworkRulesResource
{
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$user = $this->security->getUser();
if (!$user instanceof SecurityUser) {
throw new UnauthorizedHttpException('Bearer', 'Authentification requise.');
}
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('Seuls les administrateurs peuvent modifier les règles de devoirs.');
}
try {
$command = new UpdateHomeworkRulesCommand(
tenantId: (string) $this->tenantContext->getCurrentTenantId(),
rules: $data->rules ?? [],
enforcementMode: $data->enforcementMode ?? 'soft',
enabled: $data->enabled ?? true,
changedBy: $user->userId(),
);
$homeworkRules = ($this->handler)($command);
foreach ($homeworkRules->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
return HomeworkRulesResource::fromDomain($homeworkRules);
} catch (HomeworkRuleParamsInvalidException|ValueError $e) {
throw new BadRequestHttpException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Domain\Repository\HomeworkRulesHistoryRepository;
use App\Administration\Infrastructure\Api\Resource\HomeworkRulesHistoryResource;
use App\Shared\Infrastructure\Tenant\TenantContext;
use function is_string;
use function json_decode;
use const JSON_THROW_ON_ERROR;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour l'historique des modifications des règles de devoirs.
*
* @implements ProviderInterface<HomeworkRulesHistoryResource>
*/
final readonly class HomeworkRulesHistoryProvider implements ProviderInterface
{
public function __construct(
private HomeworkRulesHistoryRepository $historyRepository,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
) {
}
/**
* @return HomeworkRulesHistoryResource[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
if (!$this->authorizationChecker->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('Seuls les administrateurs peuvent voir l\'historique des règles.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$rows = $this->historyRepository->findByTenant(
$this->tenantContext->getCurrentTenantId(),
);
$resources = [];
foreach ($rows as $row) {
$resource = new HomeworkRulesHistoryResource();
/** @var string $id */
$id = $row['id'];
$resource->id = $id;
/** @var string|null $previousRules */
$previousRules = $row['previous_rules'];
if (is_string($previousRules)) {
/** @var array<int, array{type: string, params: array<string, mixed>}> $decoded */
$decoded = json_decode($previousRules, true, 512, JSON_THROW_ON_ERROR);
$resource->previousRules = $decoded;
}
/** @var string $newRules */
$newRules = $row['new_rules'];
/** @var array<int, array{type: string, params: array<string, mixed>}> $decodedNew */
$decodedNew = json_decode($newRules, true, 512, JSON_THROW_ON_ERROR);
$resource->newRules = $decodedNew;
/** @var string $enforcementMode */
$enforcementMode = $row['enforcement_mode'];
$resource->enforcementMode = $enforcementMode;
$resource->enabled = (bool) $row['enabled'];
/** @var string $changedBy */
$changedBy = $row['changed_by'];
$resource->changedBy = $changedBy;
/** @var string $changedAt */
$changedAt = $row['changed_at'];
$resource->changedAt = $changedAt;
$resources[] = $resource;
}
return $resources;
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
use App\Administration\Domain\Repository\HomeworkRulesRepository;
use App\Administration\Infrastructure\Api\Resource\HomeworkRulesResource;
use App\Shared\Domain\Clock;
use App\Shared\Infrastructure\Tenant\TenantContext;
use Override;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
/**
* State Provider pour récupérer la configuration des règles de devoirs.
*
* @implements ProviderInterface<HomeworkRulesResource>
*/
final readonly class HomeworkRulesProvider implements ProviderInterface
{
public function __construct(
private HomeworkRulesRepository $homeworkRulesRepository,
private TenantContext $tenantContext,
private AuthorizationCheckerInterface $authorizationChecker,
private Clock $clock,
) {
}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): HomeworkRulesResource
{
if (!$this->authorizationChecker->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('Seuls les administrateurs peuvent gérer les règles de devoirs.');
}
if (!$this->tenantContext->hasTenant()) {
throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.');
}
$tenantId = $this->tenantContext->getCurrentTenantId();
$homeworkRules = $this->homeworkRulesRepository->findByTenantId($tenantId);
if ($homeworkRules === null) {
$homeworkRules = HomeworkRules::creer(
tenantId: $tenantId,
now: $this->clock->now(),
);
$this->homeworkRulesRepository->save($homeworkRules);
}
return HomeworkRulesResource::fromDomain($homeworkRules);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Administration\Infrastructure\Api\Provider\HomeworkRulesHistoryProvider;
/**
* DTO représentant une entrée d'historique des règles de devoirs.
*/
#[ApiResource(
shortName: 'HomeworkRulesHistory',
operations: [
new GetCollection(
uriTemplate: '/settings/homework-rules/history',
provider: HomeworkRulesHistoryProvider::class,
name: 'get_homework_rules_history',
),
],
)]
final class HomeworkRulesHistoryResource
{
#[ApiProperty(identifier: true)]
public ?string $id = null;
/** @var array<int, array{type: string, params: array<string, mixed>}>|null */
public ?array $previousRules = null;
/** @var array<int, array{type: string, params: array<string, mixed>}> */
public array $newRules = [];
public string $enforcementMode = '';
public bool $enabled = true;
public string $changedBy = '';
public string $changedAt = '';
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Api\Resource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
use App\Administration\Infrastructure\Api\Processor\UpdateHomeworkRulesProcessor;
use App\Administration\Infrastructure\Api\Provider\HomeworkRulesProvider;
use function array_map;
use DateTimeImmutable;
use Symfony\Component\Validator\Constraints as Assert;
/**
* API Resource pour la configuration des règles de devoirs.
*
* @see FR81 - Configurer règles de timing des devoirs
*/
#[ApiResource(
shortName: 'HomeworkRules',
operations: [
new Get(
uriTemplate: '/settings/homework-rules',
provider: HomeworkRulesProvider::class,
name: 'get_homework_rules',
),
new Put(
uriTemplate: '/settings/homework-rules',
read: false,
processor: UpdateHomeworkRulesProcessor::class,
name: 'update_homework_rules',
),
],
)]
final class HomeworkRulesResource
{
#[ApiProperty(identifier: true)]
public ?string $id = null;
/** @var array<int, array{type: string, params: array<string, mixed>}>|null */
#[Assert\NotNull(message: 'Les règles sont obligatoires.')]
public ?array $rules = null;
#[Assert\NotBlank(message: 'Le mode d\'application est obligatoire.')]
#[Assert\Choice(choices: ['soft', 'hard', 'disabled'], message: 'Mode invalide.')]
public ?string $enforcementMode = null;
public ?bool $enabled = null;
public ?DateTimeImmutable $createdAt = null;
public ?DateTimeImmutable $updatedAt = null;
public static function fromDomain(HomeworkRules $homeworkRules): self
{
$resource = new self();
$resource->id = (string) $homeworkRules->id;
$resource->rules = array_map(
static fn (HomeworkRule $rule): array => $rule->toArray(),
$homeworkRules->rules,
);
$resource->enforcementMode = $homeworkRules->enforcementMode->value;
$resource->enabled = $homeworkRules->enabled;
$resource->createdAt = $homeworkRules->createdAt;
$resource->updatedAt = $homeworkRules->updatedAt;
return $resource;
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\HomeworkRulesHistoryRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use function json_encode;
use const JSON_THROW_ON_ERROR;
use Override;
final readonly class DoctrineHomeworkRulesHistoryRepository implements HomeworkRulesHistoryRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function record(
TenantId $tenantId,
?array $previousRules,
array $newRules,
EnforcementMode $enforcementMode,
bool $enabled,
UserId $changedBy,
DateTimeImmutable $changedAt,
): void {
$previousJson = $previousRules !== null
? json_encode(
array_map(static fn (HomeworkRule $r): array => $r->toArray(), $previousRules),
JSON_THROW_ON_ERROR,
)
: null;
$newJson = json_encode(
array_map(static fn (HomeworkRule $r): array => $r->toArray(), $newRules),
JSON_THROW_ON_ERROR,
);
$this->connection->executeStatement(
'INSERT INTO homework_rules_history (tenant_id, previous_rules, new_rules, enforcement_mode, enabled, changed_by, changed_at)
VALUES (:tenant_id, :previous_rules, :new_rules, :enforcement_mode, :enabled, :changed_by, :changed_at)',
[
'tenant_id' => (string) $tenantId,
'previous_rules' => $previousJson,
'new_rules' => $newJson,
'enforcement_mode' => $enforcementMode->value,
'enabled' => $enabled ? 'true' : 'false',
'changed_by' => (string) $changedBy,
'changed_at' => $changedAt->format(DateTimeImmutable::ATOM),
],
);
}
#[Override]
public function findByTenant(TenantId $tenantId, int $limit = 50): array
{
/** @var array<array{id: string, previous_rules: string|null, new_rules: string, enforcement_mode: string, enabled: bool, changed_by: string, changed_at: string}> $rows */
$rows = $this->connection->fetchAllAssociative(
'SELECT id, previous_rules, new_rules, enforcement_mode, enabled, changed_by, changed_at
FROM homework_rules_history
WHERE tenant_id = :tenant_id
ORDER BY changed_at DESC
LIMIT :limit',
['tenant_id' => (string) $tenantId, 'limit' => $limit],
);
return $rows;
}
}

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\Doctrine;
use App\Administration\Domain\Exception\HomeworkRulesNotFoundException;
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRulesId;
use App\Administration\Domain\Repository\HomeworkRulesRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use function is_string;
use function json_decode;
use function json_encode;
use const JSON_THROW_ON_ERROR;
use Override;
final readonly class DoctrineHomeworkRulesRepository implements HomeworkRulesRepository
{
public function __construct(
private Connection $connection,
) {
}
#[Override]
public function save(HomeworkRules $homeworkRules): void
{
$rulesJson = json_encode(
array_map(
static fn (HomeworkRule $rule): array => $rule->toArray(),
$homeworkRules->rules,
),
JSON_THROW_ON_ERROR,
);
$this->connection->executeStatement(
'INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at)
VALUES (:id, :tenant_id, :rules, :enforcement_mode, :enabled, :created_at, :updated_at)
ON CONFLICT (tenant_id) DO UPDATE SET
rules = EXCLUDED.rules,
enforcement_mode = EXCLUDED.enforcement_mode,
enabled = EXCLUDED.enabled,
updated_at = EXCLUDED.updated_at',
[
'id' => (string) $homeworkRules->id,
'tenant_id' => (string) $homeworkRules->tenantId,
'rules' => $rulesJson,
'enforcement_mode' => $homeworkRules->enforcementMode->value,
'enabled' => $homeworkRules->enabled ? 'true' : 'false',
'created_at' => $homeworkRules->createdAt->format(DateTimeImmutable::ATOM),
'updated_at' => $homeworkRules->updatedAt->format(DateTimeImmutable::ATOM),
],
);
}
#[Override]
public function get(TenantId $tenantId): HomeworkRules
{
$rules = $this->findByTenantId($tenantId);
if ($rules === null) {
throw HomeworkRulesNotFoundException::pourTenant($tenantId);
}
return $rules;
}
#[Override]
public function findByTenantId(TenantId $tenantId): ?HomeworkRules
{
$row = $this->connection->fetchAssociative(
'SELECT * FROM homework_rules WHERE tenant_id = :tenant_id',
['tenant_id' => (string) $tenantId],
);
if ($row === false) {
return null;
}
return $this->hydrate($row);
}
/**
* @param array<string, mixed> $row
*/
private function hydrate(array $row): HomeworkRules
{
/** @var string $id */
$id = $row['id'];
/** @var string $tenantId */
$tenantId = $row['tenant_id'];
/** @var string $rulesJson */
$rulesJson = $row['rules'];
/** @var string $enforcementMode */
$enforcementMode = $row['enforcement_mode'];
/** @var bool|string $enabled */
$enabled = $row['enabled'];
/** @var string $createdAt */
$createdAt = $row['created_at'];
/** @var string $updatedAt */
$updatedAt = $row['updated_at'];
/** @var array<int, array{type: string, params: array<string, mixed>}> $rulesData */
$rulesData = json_decode($rulesJson, true, 512, JSON_THROW_ON_ERROR);
return HomeworkRules::reconstitute(
id: HomeworkRulesId::fromString($id),
tenantId: TenantId::fromString($tenantId),
rules: array_map(HomeworkRule::fromArray(...), $rulesData),
enforcementMode: EnforcementMode::from($enforcementMode),
enabled: is_string($enabled) ? $enabled === 'true' || $enabled === 't' : (bool) $enabled,
createdAt: new DateTimeImmutable($createdAt),
updatedAt: new DateTimeImmutable($updatedAt),
);
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Model\HomeworkRules\EnforcementMode;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRule;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\HomeworkRulesHistoryRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function array_slice;
use DateTimeImmutable;
use function json_encode;
use const JSON_THROW_ON_ERROR;
use Override;
use Ramsey\Uuid\Uuid;
final class InMemoryHomeworkRulesHistoryRepository implements HomeworkRulesHistoryRepository
{
/** @var array<string, list<array{id: string, previous_rules: string|null, new_rules: string, enforcement_mode: string, enabled: bool, changed_by: string, changed_at: string}>> */
private array $history = [];
#[Override]
public function record(
TenantId $tenantId,
?array $previousRules,
array $newRules,
EnforcementMode $enforcementMode,
bool $enabled,
UserId $changedBy,
DateTimeImmutable $changedAt,
): void {
$key = (string) $tenantId;
$previousJson = $previousRules !== null
? json_encode(
array_map(static fn (HomeworkRule $r): array => $r->toArray(), $previousRules),
JSON_THROW_ON_ERROR,
)
: null;
$this->history[$key][] = [
'id' => Uuid::uuid7()->toString(),
'previous_rules' => $previousJson,
'new_rules' => json_encode(
array_map(static fn (HomeworkRule $r): array => $r->toArray(), $newRules),
JSON_THROW_ON_ERROR,
),
'enforcement_mode' => $enforcementMode->value,
'enabled' => $enabled,
'changed_by' => (string) $changedBy,
'changed_at' => $changedAt->format(DateTimeImmutable::ATOM),
];
}
#[Override]
public function findByTenant(TenantId $tenantId, int $limit = 50): array
{
$entries = $this->history[(string) $tenantId] ?? [];
return array_slice(array_reverse($entries), 0, $limit);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Administration\Infrastructure\Persistence\InMemory;
use App\Administration\Domain\Exception\HomeworkRulesNotFoundException;
use App\Administration\Domain\Model\HomeworkRules\HomeworkRules;
use App\Administration\Domain\Repository\HomeworkRulesRepository;
use App\Shared\Domain\Tenant\TenantId;
use Override;
final class InMemoryHomeworkRulesRepository implements HomeworkRulesRepository
{
/** @var array<string, HomeworkRules> Indexed by tenant_id */
private array $byTenant = [];
#[Override]
public function save(HomeworkRules $homeworkRules): void
{
$this->byTenant[(string) $homeworkRules->tenantId] = $homeworkRules;
}
#[Override]
public function get(TenantId $tenantId): HomeworkRules
{
$rules = $this->findByTenantId($tenantId);
if ($rules === null) {
throw HomeworkRulesNotFoundException::pourTenant($tenantId);
}
return $rules;
}
#[Override]
public function findByTenantId(TenantId $tenantId): ?HomeworkRules
{
return $this->byTenant[(string) $tenantId] ?? null;
}
}