Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ When adding a class for a plugin (a repository, a state processor/provider, a se

Note: `Validator/Constraints/` classes also drop the plugin name prefix from the class name once nested (the namespace already carries it), e.g. `TimeAndTravelDeclarationNotLocked` becomes `Plugin\TimeAndTravelDeclaration\NotLocked`. `Repository/`, `State/` and `Service/` classes keep their full descriptive name even when nested (e.g. `Plugin\Loan\LoanItemRepository`, not `Plugin\Loan\ItemRepository`) — only shorten the class name where doing so doesn't blur which entity/feature it's about.

**Enabling and gating plugins: `App\Enum\ClubPlugin`** (`src/Enum/ClubPlugin.php`) is the single place that maps a plugin to its per-club flag (`Club::get*Enabled()`) and to the URL prefixes it owns (relative to `/clubs/{id}`). `ClubLinkedEntitySubscriber` uses it to answer `423 Locked` when a club calls a disabled plugin's endpoints. When adding a plugin:

1. Add the `<name>Enabled` column on `Club` (with a migration), as for the existing ones.
2. Add a case to `ClubPlugin` and fill in both `match` arms (`urlPrefixes()`, `isEnabledFor()`); `match` fails on an unhandled case, so a forgotten arm is caught.

### Configuration (`config/`)
```
config/
Expand Down Expand Up @@ -425,7 +430,7 @@ security: "is_granted('" . Permission::EMAIL_EDIT->value . "', request)"
- Single source of truth: default values, computed labels, and validation rules belong here, not duplicated in the frontend. Only apply a default when the client omits the field entirely (don't let the frontend send a placeholder value that masks the backend default).

### 3. **Modular Architecture**
- Plugin-based feature system
- Plugin-based feature system (enabled per club, gated through the `ClubPlugin` enum)
- Clear separation of concerns
- Flexible configuration per club

Expand Down
6 changes: 6 additions & 0 deletions config/packages/framework.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ when@test:
test: true
session:
storage_factory_id: session.storage.factory.mock_file
# The messenger restart signal (written by messenger:stop-workers) lives in a cache shared on disk by every
# paratest process: another process saving the SMTP settings would stop this process' test worker mid-queue.
cache:
pools:
cache.messenger.restart_workers_signal:
adapter: cache.adapter.array
17 changes: 0 additions & 17 deletions src/Entity/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,6 @@ class Config {
#[Groups(['config'])]
private ?array $modules = null;

#[Groups(['config'])]
private ?array $profiles = null;

public function getId(): string {
return $this->id;
}
Expand Down Expand Up @@ -90,18 +87,4 @@ public function addModule(string $name, array $moduleConfig): Config {
$this->modules[$name] = $moduleConfig;
return $this;
}

public function getProfiles(): ?array {
return $this->profiles;
}

public function setProfiles(?array $profiles): Config {
$this->profiles = $profiles;
return $this;
}

public function addProfileModule(string $profile, string $name, array $moduleConfig): Config {
$this->profiles[$profile][$name] = $moduleConfig;
return $this;
}
}
50 changes: 50 additions & 0 deletions src/Enum/ClubPlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace App\Enum;

use App\Entity\Club;

enum ClubPlugin: string {
case presences = 'Presences';
case sales = 'Sales';
case loans = 'Loans';
case time_and_travel = 'TimeAndTravel';

/**
* Request paths (relative to /clubs/{id}) that require this plugin to be enabled.
*
* @return string[]
*/
public function urlPrefixes(): array {
return match ($this) {
self::presences => ['/member-presences', '/external-presences'],
self::sales => ['/sales', '/sale-payment-modes', '/inventory-'],
self::loans => ['/loans', '/loan-items', '/loan-categories', '/loan-recordings', '/loan-recording-types'],
self::time_and_travel => ['/time-and-travel-declarations', '/time-and-travel-exports', '/member-vehicles'],
};
}

public function isEnabledFor(Club $club): bool {
return match ($this) {
self::presences => $club->getPresencesEnabled(),
self::sales => (bool) $club->getSalesEnabled(),
self::loans => $club->getLoansEnabled(),
self::time_and_travel => $club->getTimeAndTravelEnabled(),
};
}

/**
* Plugin owning the given path, or null when the path is not plugin specific.
*/
public static function fromPath(string $path): ?self {
foreach (self::cases() as $plugin) {
foreach ($plugin->urlPrefixes() as $prefix) {
if (str_starts_with($path, $prefix)) {
return $plugin;
}
}
}

return null;
}
}
55 changes: 4 additions & 51 deletions src/EventSubscriber/Kernel/ClubLinkedEntitySubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use ApiPlatform\Symfony\EventListener\EventPriorities;
use App\Entity\Club;
use App\Entity\Interface\ClubLinkedEntityInterface;
use App\Enum\ClubPlugin;
use App\Enum\UserRole;
use App\Service\RequestService;
use Symfony\Bundle\SecurityBundle\Security;
Expand Down Expand Up @@ -81,58 +82,10 @@ private function validateClubLinkedRequest(RequestEvent $event): void {
$this->verifyAccessForPlugins($event, $club, "/$matches[2]");
}
private function verifyAccessForPlugins(RequestEvent $event, Club $club, string $cleanedPath): void {
$presencesPattern = [
'/member-presences',
'/external-presences',
];
$checked = $this->verifyAccessForPlugin("Presences", $presencesPattern, $club->getPresencesEnabled(), $cleanedPath, $event);
if ($checked) {
return;
$plugin = ClubPlugin::fromPath($cleanedPath);
if ($plugin && !$plugin->isEnabledFor($club)) {
$this->throwLockedException($event, "{$plugin->value} plugin not activated.");
}

$salesPattern = [
'/sales',
'/sale-payment-modes',
'/inventory-'
];
$checked = $this->verifyAccessForPlugin("Sales", $salesPattern, $club->getSalesEnabled(), $cleanedPath, $event);
if ($checked) {
return;
}

$loansPattern = [
'/loans',
'/loan-items',
'/loan-categories',
'/loan-recordings',
'/loan-recording-types',
];
$checked = $this->verifyAccessForPlugin("Loans", $loansPattern, $club->getLoansEnabled(), $cleanedPath, $event);
if ($checked) {
return;
}

$timeAndTravelPattern = [
'/time-and-travel-declarations',
'/time-and-travel-exports',
'/member-vehicles',
];
$checked = $this->verifyAccessForPlugin("TimeAndTravel", $timeAndTravelPattern, $club->getTimeAndTravelEnabled(), $cleanedPath, $event);
if ($checked) {
return;
}
}
private function verifyAccessForPlugin(string $pluginName, array $urlPatterns, bool $pluginEnabled, string $requestedPath, RequestEvent $event): bool {
foreach ($urlPatterns as $urlPattern) {
if (str_starts_with($requestedPath, (string) $urlPattern)) {
if (!$pluginEnabled) {
$this->throwLockedException($event, "$pluginName plugin not activated.");
}
return true;
}
}

return false;
}
private function throwLockedException(RequestEvent $event, string $message = ''): void {
$error = Error::createFromException(new \Exception($message), Response::HTTP_LOCKED);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Message\TimeAndTravelExportRegenerateMessage;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;

Expand All @@ -15,33 +16,25 @@
* fails for good would leave the export "in progress" forever (and its buttons disabled). Once
* the retries are exhausted, clear the flag and mark the export as failed, so the UI can offer to regenerate it.
*/
final readonly class TimeAndTravelExportMessengerSubscriber implements EventSubscriberInterface {

public static function getSubscribedEvents(): array {
return [
WorkerMessageFailedEvent::class => [
['messageFailed', 10],
],
];
}

public function __construct(
private Connection $connection,
private EntityManagerInterface $entityManager,
) {
}

public function messageFailed(WorkerMessageFailedEvent $event): void {
$message = $event->getEnvelope()->getMessage();
if ($event->willRetry() || !$message instanceof TimeAndTravelExportRegenerateMessage) {
return;
final readonly class TimeAndTravelExportMessengerSubscriber
{
public function __construct(
private Connection $connection,
private EntityManagerInterface $entityManager,
) {
}
#[AsEventListener(event: WorkerMessageFailedEvent::class, priority: 10)]
public function messageFailed(WorkerMessageFailedEvent $event): void {
$message = $event->getEnvelope()->getMessage();
if ($event->willRetry() || !$message instanceof TimeAndTravelExportRegenerateMessage) {
return;
}

// Plain SQL: the entity manager is closed when the failure came from a flush
$table = $this->entityManager->getClassMetadata(TimeAndTravelExport::class)->getTableName();
$this->connection->executeStatement(
sprintf('UPDATE %s SET generation_status = :status WHERE uuid = :uuid', $this->connection->quoteSingleIdentifier($table)),
['status' => TimeAndTravelExportGenerationStatus::failed->value, 'uuid' => $message->getExportUuid()],
);
}
// Plain SQL: the entity manager is closed when the failure came from a flush
$table = $this->entityManager->getClassMetadata(TimeAndTravelExport::class)->getTableName();
$this->connection->executeStatement(
sprintf('UPDATE %s SET generation_status = :status WHERE uuid = :uuid', $this->connection->quoteSingleIdentifier($table)),
['status' => TimeAndTravelExportGenerationStatus::failed->value, 'uuid' => $message->getExportUuid()],
);
}
}
16 changes: 0 additions & 16 deletions src/State/ConfigProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,29 +64,13 @@ public function getUserConfig(Config $config, User $user): void {
$config->setId('user');

foreach ($user->getLinkedProfiles() as $profile) {
$id = $profile->getId();
/** @var Club $club */
$club = $profile->getClub();

// User is admin of any club. He has the right to see the version
if ($this->authorizationChecker->isGranted(ClubRole::admin->value, $club)) {
$this->displayAppVersion($config);
}

// User is badger or supervisor
if ($this->authorizationChecker->isGranted(ClubRole::supervisor->value, $club) ||
$this->authorizationChecker->isGranted(ClubRole::badger->value, $club)) {
$config->addProfileModule($id, 'presences', [
'enabled' => $club->getPresencesEnabled(),
]);
}

// User a supervisor, he can have access to the sale management
if ($this->authorizationChecker->isGranted(ClubRole::supervisor->value, $club)) {
$config->addProfileModule($id, 'sales', [
'enabled' => $club->getSalesEnabled(),
]);
}
}
}

Expand Down
Loading