feat(client-portal) : phase 1 foundations — ROLE_CLIENT hardening + ClientTicket (back)

LST-69 (3.2) phase 1. New ClientPortal module + security foundations for the
client portal (spec docs/superpowers/specs/2026-03-15-client-portal-design.md).

- Security: User::getRoles() no longer adds ROLE_USER to ROLE_CLIENT users;
  role_hierarchy ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]. Existing Task/Project/
  Client/TimeEntry/metadata endpoints already required ROLE_USER -> a pure
  ROLE_CLIENT is walled off (verified: 403).
- User (Core): client (ManyToOne ClientInterface, SET NULL) + allowedProjects
  (ManyToMany ProjectInterface). UserInterface extended (getClient/
  getAllowedProjects).
- New ClientTicket entity (module ClientPortal) + enums + repository + API with
  per-client isolation (ClientTicketProvider: own tickets ∩ allowedProjects),
  per-project numbering under advisory lock (rejects if user.client null),
  status transition rules. ClientTicketInterface contract for Task/TaskDocument.
- TaskDocument generalized: task nullable + clientTicket (CASCADE) + CHECK;
  per-role access. Task.clientTicket exposed in task:read.
- Additive migration; demo client fixtures.
- Tenancy tests assert the isolation invariant (a client never sees another
  client's tickets) rather than brittle absolute counts (shared test DB).

178 tests green, mapping valid, cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-21 00:46:26 +02:00
parent f4ffc02028
commit 808a290845
24 changed files with 1337 additions and 33 deletions
+2
View File
@@ -8,6 +8,7 @@ declare(strict_types=1);
*/
use App\Module\Absence\AbsenceModule;
use App\Module\ClientPortal\ClientPortalModule;
use App\Module\Core\CoreModule;
use App\Module\Directory\DirectoryModule;
use App\Module\Integration\IntegrationModule;
@@ -25,4 +26,5 @@ return [
MailModule::class,
IntegrationModule::class,
ReportingModule::class,
ClientPortalModule::class,
];
+6
View File
@@ -26,6 +26,7 @@ doctrine:
App\Shared\Domain\Contract\TaskInterface: App\Module\ProjectManagement\Domain\Entity\Task
App\Shared\Domain\Contract\TaskTagInterface: App\Module\ProjectManagement\Domain\Entity\TaskTag
App\Shared\Domain\Contract\ClientInterface: App\Module\Directory\Domain\Entity\Client
App\Shared\Domain\Contract\ClientTicketInterface: App\Module\ClientPortal\Domain\Entity\ClientTicket
mappings:
App:
type: attribute
@@ -68,6 +69,11 @@ doctrine:
is_bundle: false
dir: '%kernel.project_dir%/src/Module/Integration/Domain/Entity'
prefix: 'App\Module\Integration\Domain\Entity'
ClientPortal:
type: attribute
is_bundle: false
dir: '%kernel.project_dir%/src/Module/ClientPortal/Domain/Entity'
prefix: 'App\Module\ClientPortal\Domain\Entity'
controller_resolver:
auto_mapping: false
+1 -1
View File
@@ -1,6 +1,6 @@
security:
role_hierarchy:
ROLE_ADMIN: [ROLE_USER]
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
+2
View File
@@ -111,6 +111,8 @@ services:
App\Module\Directory\Domain\Repository\ClientRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineClientRepository'
App\Module\ClientPortal\Domain\Repository\ClientTicketRepositoryInterface: '@App\Module\ClientPortal\Infrastructure\Doctrine\DoctrineClientTicketRepository'
App\Module\Directory\Domain\Repository\ProspectRepositoryInterface: '@App\Module\Directory\Infrastructure\Doctrine\DoctrineProspectRepository'
App\Module\Mail\Domain\Repository\MailConfigurationRepositoryInterface: '@App\Module\Mail\Infrastructure\Doctrine\DoctrineMailConfigurationRepository'
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Client portal — phase 1 foundations (additive).
*
* - Creates the client_ticket table (per-project numbering, FK project/submittedBy,
* Timestampable/Blamable columns, unique (project_id, number), filter indexes).
* - Adds user.client_id (FK client, SET NULL) and the user_allowed_projects join table.
* - Adds task.client_ticket_id (FK client_ticket, SET NULL).
* - Generalises task_document: task_id becomes nullable, adds client_ticket_id
* (FK client_ticket, CASCADE) and a CHECK enforcing that a document is bound
* to either a task or a client ticket.
*
* Lowercase SQL columns; "user" table is quoted. FK/index names mirror Doctrine's
* generated identifiers so doctrine:schema:validate stays clean. down() reverses.
*/
final class Version20260621120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Client portal phase 1: client_ticket table, user.client_id, user_allowed_projects, task/task_document client ticket links (additive)';
}
public function up(Schema $schema): void
{
// --- client_ticket ---
$this->addSql('CREATE TABLE client_ticket (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, number INT NOT NULL, type VARCHAR(16) NOT NULL, title VARCHAR(255) NOT NULL, description TEXT NOT NULL, url VARCHAR(1024) DEFAULT NULL, status VARCHAR(16) NOT NULL, status_comment TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, project_id INT NOT NULL, submitted_by_id INT DEFAULT NULL, created_by INT DEFAULT NULL, updated_by INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE UNIQUE INDEX uniq_client_ticket_project_number ON client_ticket (project_id, number)');
$this->addSql('CREATE INDEX idx_client_ticket_project ON client_ticket (project_id)');
$this->addSql('CREATE INDEX idx_client_ticket_submitted_by ON client_ticket (submitted_by_id)');
$this->addSql('CREATE INDEX idx_client_ticket_status_project ON client_ticket (status, project_id)');
$this->addSql('CREATE INDEX IDX_C206E610DE12AB56 ON client_ticket (created_by)');
$this->addSql('CREATE INDEX IDX_C206E61016FE72E1 ON client_ticket (updated_by)');
$this->addSql('ALTER TABLE client_ticket ADD CONSTRAINT FK_C206E610166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE client_ticket ADD CONSTRAINT FK_C206E61079F7D87D FOREIGN KEY (submitted_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE client_ticket ADD CONSTRAINT FK_C206E610DE12AB56 FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE client_ticket ADD CONSTRAINT FK_C206E61016FE72E1 FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql("COMMENT ON COLUMN client_ticket.number IS 'Numero incremental unique par projet (affiche CT-XXX).'");
$this->addSql("COMMENT ON COLUMN client_ticket.type IS 'Type de ticket : bug, improvement, other.'");
$this->addSql("COMMENT ON COLUMN client_ticket.status IS 'Statut : new, in_progress, done, rejected.'");
$this->addSql("COMMENT ON COLUMN client_ticket.status_comment IS 'Commentaire du manager lors d''un changement de statut (obligatoire si rejected).'");
$this->addSql("COMMENT ON COLUMN client_ticket.url IS 'URL de la page concernee, affichee uniquement pour les bugs.'");
$this->addSql("COMMENT ON COLUMN client_ticket.submitted_by_id IS 'Utilisateur-client ayant soumis le ticket (FK user, SET NULL pour conserver l''historique).'");
$this->addSql("COMMENT ON COLUMN client_ticket.created_at IS 'Creation timestamp (Timestampable, set on prePersist)'");
$this->addSql("COMMENT ON COLUMN client_ticket.updated_at IS 'Last update timestamp (Timestampable, set on prePersist/preUpdate)'");
$this->addSql("COMMENT ON COLUMN client_ticket.created_by IS 'User who created the entry (Blamable, FK user.id, SET NULL on delete)'");
$this->addSql("COMMENT ON COLUMN client_ticket.updated_by IS 'User who last updated the entry (Blamable, FK user.id, SET NULL on delete)'");
// --- user.client_id ---
$this->addSql('ALTER TABLE "user" ADD client_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD CONSTRAINT FK_8D93D64919EB6921 FOREIGN KEY (client_id) REFERENCES client (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_8D93D64919EB6921 ON "user" (client_id)');
$this->addSql('COMMENT ON COLUMN "user".client_id IS \'Client auquel appartient l\'\'utilisateur (FK client, SET NULL). null = utilisateur interne.\'');
// --- user_allowed_projects (ManyToMany) ---
$this->addSql('CREATE TABLE user_allowed_projects (user_id INT NOT NULL, project_id INT NOT NULL, PRIMARY KEY (user_id, project_id))');
$this->addSql('CREATE INDEX IDX_B3E0FC97A76ED395 ON user_allowed_projects (user_id)');
$this->addSql('CREATE INDEX IDX_B3E0FC97166D1F9C ON user_allowed_projects (project_id)');
$this->addSql('ALTER TABLE user_allowed_projects ADD CONSTRAINT FK_B3E0FC97A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE user_allowed_projects ADD CONSTRAINT FK_B3E0FC97166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE NOT DEFERRABLE');
// --- task.client_ticket_id ---
$this->addSql('ALTER TABLE task ADD client_ticket_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE task ADD CONSTRAINT FK_527EDB259B2097DD FOREIGN KEY (client_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_527EDB259B2097DD ON task (client_ticket_id)');
$this->addSql("COMMENT ON COLUMN task.client_ticket_id IS 'Lien manuel optionnel vers un ticket client (FK client_ticket, SET NULL).'");
// --- task_document generalisation ---
$this->addSql('ALTER TABLE task_document ALTER task_id DROP NOT NULL');
$this->addSql('ALTER TABLE task_document ADD client_ticket_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE task_document ADD CONSTRAINT FK_98A9603A9B2097DD FOREIGN KEY (client_ticket_id) REFERENCES client_ticket (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_98A9603A9B2097DD ON task_document (client_ticket_id)');
$this->addSql('ALTER TABLE task_document ADD CONSTRAINT chk_task_document_target CHECK (task_id IS NOT NULL OR client_ticket_id IS NOT NULL)');
$this->addSql("COMMENT ON COLUMN task_document.client_ticket_id IS 'Ticket client auquel le document est rattache (FK client_ticket, CASCADE). Alternative a task_id.'");
}
public function down(Schema $schema): void
{
// task_document
$this->addSql('ALTER TABLE task_document DROP CONSTRAINT chk_task_document_target');
$this->addSql('ALTER TABLE task_document DROP CONSTRAINT FK_98A9603A9B2097DD');
$this->addSql('DROP INDEX IDX_98A9603A9B2097DD');
$this->addSql('ALTER TABLE task_document DROP client_ticket_id');
$this->addSql('ALTER TABLE task_document ALTER task_id SET NOT NULL');
// task
$this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB259B2097DD');
$this->addSql('DROP INDEX IDX_527EDB259B2097DD');
$this->addSql('ALTER TABLE task DROP client_ticket_id');
// user_allowed_projects
$this->addSql('ALTER TABLE user_allowed_projects DROP CONSTRAINT FK_B3E0FC97A76ED395');
$this->addSql('ALTER TABLE user_allowed_projects DROP CONSTRAINT FK_B3E0FC97166D1F9C');
$this->addSql('DROP TABLE user_allowed_projects');
// user.client_id
$this->addSql('ALTER TABLE "user" DROP CONSTRAINT FK_8D93D64919EB6921');
$this->addSql('DROP INDEX IDX_8D93D64919EB6921');
$this->addSql('ALTER TABLE "user" DROP client_id');
// client_ticket
$this->addSql('ALTER TABLE client_ticket DROP CONSTRAINT FK_C206E610166D1F9C');
$this->addSql('ALTER TABLE client_ticket DROP CONSTRAINT FK_C206E61079F7D87D');
$this->addSql('ALTER TABLE client_ticket DROP CONSTRAINT FK_C206E610DE12AB56');
$this->addSql('ALTER TABLE client_ticket DROP CONSTRAINT FK_C206E61016FE72E1');
$this->addSql('DROP TABLE client_ticket');
}
}
+50
View File
@@ -10,6 +10,9 @@ use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
use App\Module\ClientPortal\Domain\Enum\ClientTicketType;
use App\Module\Core\Application\Rbac\RbacSeeder;
use App\Module\Core\Domain\Entity\User;
use App\Module\Directory\Domain\Entity\Client;
@@ -215,6 +218,53 @@ class AppFixtures extends Fixture
$projectInterne->setWorkflow($standardWorkflow);
$manager->persist($projectInterne);
// Client portal users (ROLE_CLIENT) — linked to a client + allowed projects.
$clientUserLiot = new User();
$clientUserLiot->setUsername('client-liot');
$clientUserLiot->setFirstName('Camille');
$clientUserLiot->setLastName('LIOT');
$clientUserLiot->setRoles(['ROLE_CLIENT']);
$clientUserLiot->setPassword($this->passwordHasher->hashPassword($clientUserLiot, 'client-liot'));
$clientUserLiot->setClient($clientLiot);
$clientUserLiot->addAllowedProject($projectSirh);
$manager->persist($clientUserLiot);
$clientUserAcme = new User();
$clientUserAcme->setUsername('client-acme');
$clientUserAcme->setFirstName('Sophie');
$clientUserAcme->setLastName('ACME');
$clientUserAcme->setRoles(['ROLE_CLIENT']);
$clientUserAcme->setPassword($this->passwordHasher->hashPassword($clientUserAcme, 'client-acme'));
$clientUserAcme->setClient($clientAcme);
$clientUserAcme->addAllowedProject($projectCrm);
$manager->persist($clientUserAcme);
// Demo client tickets.
$ticketLiot = new ClientTicket();
$ticketLiot->setNumber(1);
$ticketLiot->setType(ClientTicketType::Bug);
$ticketLiot->setTitle('Erreur lors de l\'export des congés');
$ticketLiot->setDescription('L\'export PDF des congés échoue avec une erreur 500.');
$ticketLiot->setUrl('https://app.example.com/sirh/conges');
$ticketLiot->setStatus(ClientTicketStatus::New);
$ticketLiot->setProject($projectSirh);
$ticketLiot->setSubmittedBy($clientUserLiot);
$ticketLiot->setCreatedAt(new DateTimeImmutable());
$ticketLiot->setUpdatedAt(new DateTimeImmutable());
$manager->persist($ticketLiot);
$ticketAcme = new ClientTicket();
$ticketAcme->setNumber(1);
$ticketAcme->setType(ClientTicketType::Improvement);
$ticketAcme->setTitle('Ajouter un filtre par commercial');
$ticketAcme->setDescription('Pouvoir filtrer la liste des opportunités par commercial assigné.');
$ticketAcme->setStatus(ClientTicketStatus::InProgress);
$ticketAcme->setProject($projectCrm);
$ticketAcme->setSubmittedBy($clientUserAcme);
$ticketAcme->setCreatedAt(new DateTimeImmutable());
$ticketAcme->setUpdatedAt(new DateTimeImmutable());
$manager->persist($ticketAcme);
// Task Efforts
$effortS = new TaskEffort();
$effortS->setLabel('S');
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal;
use App\Shared\Domain\Module\ModuleInterface;
final class ClientPortalModule implements ModuleInterface
{
public static function id(): string
{
return 'client-portal';
}
public static function label(): string
{
return 'Portail client';
}
public static function isRequired(): bool
{
return false;
}
/**
* Permissions RBAC fin du Module ClientPortal.
*
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
* reste pilotée par ROLE_CLIENT/ROLE_ADMIN sur les opérations.
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'client-portal.tickets.view', 'label' => 'Voir les tickets client'],
['code' => 'client-portal.tickets.manage', 'label' => 'Gérer les tickets client'],
];
}
}
@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
use App\Module\ClientPortal\Domain\Enum\ClientTicketType;
use App\Module\ClientPortal\Infrastructure\ApiPlatform\State\ClientTicketNumberProcessor;
use App\Module\ClientPortal\Infrastructure\ApiPlatform\State\ClientTicketProvider;
use App\Module\ClientPortal\Infrastructure\ApiPlatform\State\ClientTicketStatusProcessor;
use App\Module\ClientPortal\Infrastructure\Doctrine\DoctrineClientTicketRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\ClientTicketInterface;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('ROLE_CLIENT') or is_granted('ROLE_ADMIN')",
provider: ClientTicketProvider::class,
),
new Get(
security: "is_granted('ROLE_CLIENT') or is_granted('ROLE_ADMIN')",
provider: ClientTicketProvider::class,
),
new Post(
security: "is_granted('ROLE_CLIENT')",
processor: ClientTicketNumberProcessor::class,
),
new Patch(
security: "is_granted('ROLE_ADMIN')",
processor: ClientTicketStatusProcessor::class,
),
new Delete(
security: "is_granted('ROLE_ADMIN')",
),
],
normalizationContext: ['groups' => ['client_ticket:read']],
denormalizationContext: ['groups' => ['client_ticket:write']],
order: ['createdAt' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'status' => 'exact', 'submittedBy' => 'exact'])]
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineClientTicketRepository::class)]
#[ORM\Table(name: 'client_ticket')]
#[ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number'])]
#[ORM\Index(name: 'idx_client_ticket_project', columns: ['project_id'])]
#[ORM\Index(name: 'idx_client_ticket_submitted_by', columns: ['submitted_by_id'])]
#[ORM\Index(name: 'idx_client_ticket_status_project', columns: ['status', 'project_id'])]
class ClientTicket implements ClientTicketInterface, TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client_ticket:read', 'task:read'])]
private ?int $id = null;
/** Incremental number, unique per project (formatted CT-XXX in the UI). */
#[ORM\Column(type: 'integer')]
#[Groups(['client_ticket:read', 'task:read'])]
private ?int $number = null;
#[ORM\Column(type: 'string', length: 16, enumType: ClientTicketType::class)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
#[Assert\NotNull]
private ?ClientTicketType $type = null;
#[ORM\Column(length: 255)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
#[Assert\NotBlank]
private ?string $title = null;
#[ORM\Column(type: 'text')]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
#[Assert\NotBlank]
private ?string $description = null;
/** Displayed only when type = bug (page concerned by the bug). */
#[ORM\Column(length: 1024, nullable: true)]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?string $url = null;
#[ORM\Column(type: 'string', length: 16, enumType: ClientTicketStatus::class)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
private ClientTicketStatus $status = ClientTicketStatus::New;
/** Manager comment set when the status changes (mandatory when rejecting). */
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?string $statusComment = null;
#[ORM\ManyToOne(targetEntity: ProjectInterface::class)]
#[ORM\JoinColumn(name: 'project_id', nullable: false, onDelete: 'CASCADE')]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
#[Assert\NotNull]
private ?ProjectInterface $project = null;
/** Client user who submitted the ticket. ON DELETE SET NULL — keep history. */
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'submitted_by_id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['client_ticket:read'])]
private ?UserInterface $submittedBy = null;
public function getId(): ?int
{
return $this->id;
}
public function getNumber(): ?int
{
return $this->number;
}
public function setNumber(int $number): static
{
$this->number = $number;
return $this;
}
public function getTypeEnum(): ?ClientTicketType
{
return $this->type;
}
public function setType(?ClientTicketType $type): static
{
$this->type = $type;
return $this;
}
public function getType(): string
{
return $this->type?->value ?? '';
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getUrl(): ?string
{
return $this->url;
}
public function setUrl(?string $url): static
{
$this->url = $url;
return $this;
}
public function getStatusEnum(): ClientTicketStatus
{
return $this->status;
}
public function setStatus(ClientTicketStatus $status): static
{
$this->status = $status;
return $this;
}
public function getStatus(): string
{
return $this->status->value;
}
public function getStatusComment(): ?string
{
return $this->statusComment;
}
public function setStatusComment(?string $statusComment): static
{
$this->statusComment = $statusComment;
return $this;
}
public function getProject(): ?ProjectInterface
{
return $this->project;
}
public function setProject(?ProjectInterface $project): static
{
$this->project = $project;
return $this;
}
public function getSubmittedBy(): ?UserInterface
{
return $this->submittedBy;
}
public function setSubmittedBy(?UserInterface $submittedBy): static
{
$this->submittedBy = $submittedBy;
return $this;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Domain\Enum;
enum ClientTicketStatus: string
{
case New = 'new';
case InProgress = 'in_progress';
case Done = 'done';
case Rejected = 'rejected';
public function label(): string
{
return match ($this) {
self::New => 'Nouveau',
self::InProgress => 'En cours',
self::Done => 'Terminé',
self::Rejected => 'Rejeté',
};
}
/**
* Whether a transition from this status to $target is allowed.
*
* All transitions are allowed except `done` -> `new` and `rejected` -> `new`.
*/
public function canTransitionTo(self $target): bool
{
if (self::New === $target && (self::Done === $this || self::Rejected === $this)) {
return false;
}
return true;
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Domain\Enum;
enum ClientTicketType: string
{
case Bug = 'bug';
case Improvement = 'improvement';
case Other = 'other';
public function label(): string
{
return match ($this) {
self::Bug => 'Bug',
self::Improvement => 'Amélioration',
self::Other => 'Autre',
};
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Domain\Repository;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
interface ClientTicketRepositoryInterface
{
public function findById(int $id): ?ClientTicket;
/**
* Highest ticket number currently used on a given project, behind a
* PostgreSQL advisory transaction lock so concurrent inserts serialize.
* Returns 0 if the project has no ticket yet. Must run inside a transaction.
*/
public function findMaxNumberByProjectForUpdate(int $projectId): int;
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
use App\Module\ClientPortal\Domain\Repository\ClientTicketRepositoryInterface;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* Handles creation of a client ticket (POST).
*
* - Rejects users whose `client` is null (an admin inheriting ROLE_CLIENT via
* the role hierarchy cannot create a ticket).
* - Enforces that the target project belongs to the user's allowed projects.
* - Sets submittedBy, status = new, timestamps.
* - Generates the per-project incremental number behind an advisory lock so the
* unique constraint `(project_id, number)` is never violated by concurrency.
*
* @implements ProcessorInterface<ClientTicket, ClientTicket>
*/
final readonly class ClientTicketNumberProcessor implements ProcessorInterface
{
/**
* @param ProcessorInterface<ClientTicket, ClientTicket> $persistProcessor
*/
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
private ClientTicketRepositoryInterface $repository,
private EntityManagerInterface $entityManager,
private Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
{
assert($data instanceof ClientTicket);
$user = $this->security->getUser();
assert($user instanceof UserInterface);
// An admin must not be able to create a ticket even though ROLE_ADMIN
// inherits ROLE_CLIENT in the role hierarchy.
if (null === $user->getClient()) {
throw new AccessDeniedHttpException('Only client users can submit tickets.');
}
$project = $data->getProject();
if (!$project instanceof ProjectInterface) {
throw new UnprocessableEntityHttpException('A project is required.');
}
if (!$this->userMayAccessProject($user, $project)) {
throw new AccessDeniedHttpException('You are not allowed to submit tickets on this project.');
}
$data->setSubmittedBy($user);
$data->setStatus(ClientTicketStatus::New);
$data->setStatusComment(null);
$now = new DateTimeImmutable();
$data->setCreatedAt($now);
$data->setUpdatedAt($now);
return $this->entityManager->wrapInTransaction(function () use ($data, $project, $operation, $uriVariables, $context): ClientTicket {
$maxNumber = $this->repository->findMaxNumberByProjectForUpdate((int) $project->getId());
$data->setNumber($maxNumber + 1);
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
assert($result instanceof ClientTicket);
return $result;
});
}
private function userMayAccessProject(UserInterface $user, ProjectInterface $project): bool
{
foreach ($user->getAllowedProjects() as $allowed) {
if ($allowed->getId() === $project->getId()) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Provider for ClientTicket read operations.
*
* - ROLE_ADMIN: no restriction.
* - ROLE_CLIENT: only tickets the user submitted, and only on projects the
* user is allowed to access (allowedProjects).
*
* @implements ProviderInterface<ClientTicket>
*/
final readonly class ClientTicketProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|ClientTicket|null
{
$user = $this->security->getUser();
assert($user instanceof UserInterface);
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
$repo = $this->entityManager->getRepository(ClientTicket::class);
// Single item.
if (isset($uriVariables['id'])) {
$ticket = $repo->find($uriVariables['id']);
if (null === $ticket) {
return null;
}
if ($isAdmin) {
return $ticket;
}
if ($ticket->getSubmittedBy() !== $user) {
return null;
}
if (!$this->userMayAccessProject($user, $ticket->getProject())) {
return null;
}
return $ticket;
}
// Collection.
$qb = $repo->createQueryBuilder('t')
->orderBy('t.createdAt', 'DESC')
;
if (!$isAdmin) {
$qb->andWhere('t.submittedBy = :user')->setParameter('user', $user);
$allowedIds = $this->allowedProjectIds($user);
if ([] === $allowedIds) {
return [];
}
$qb->andWhere('IDENTITY(t.project) IN (:allowedProjects)')
->setParameter('allowedProjects', $allowedIds)
;
}
$filters = $context['filters'] ?? [];
if (isset($filters['project'])) {
$qb->andWhere('IDENTITY(t.project) = :project')
->setParameter('project', self::extractId($filters['project']))
;
}
if (isset($filters['status'])) {
$qb->andWhere('t.status = :status')->setParameter('status', $filters['status']);
}
if ($isAdmin && isset($filters['submittedBy'])) {
$qb->andWhere('t.submittedBy = :submittedBy')
->setParameter('submittedBy', self::extractId($filters['submittedBy']))
;
}
return $qb->getQuery()->getResult();
}
private function userMayAccessProject(UserInterface $user, ?ProjectInterface $project): bool
{
if (null === $project) {
return false;
}
return in_array($project->getId(), $this->allowedProjectIds($user), true);
}
/**
* @return list<int>
*/
private function allowedProjectIds(UserInterface $user): array
{
$ids = [];
foreach ($user->getAllowedProjects() as $project) {
$id = $project->getId();
if (null !== $id) {
$ids[] = $id;
}
}
return $ids;
}
private static function extractId(string $value): int
{
return is_numeric($value) ? (int) $value : (int) basename($value);
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* Handles status changes on a client ticket (PATCH, ROLE_ADMIN only).
*
* - Rejects the forbidden transitions `done` -> `new` and `rejected` -> `new`.
* - Requires a statusComment when moving to the `rejected` status.
* - Refreshes updatedAt.
*
* @implements ProcessorInterface<ClientTicket, ClientTicket>
*/
final readonly class ClientTicketStatusProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
{
assert($data instanceof ClientTicket);
$newStatus = $data->getStatusEnum();
$previous = $context['previous_data'] ?? null;
$oldStatus = $previous instanceof ClientTicket ? $previous->getStatusEnum() : null;
if (null !== $oldStatus && !$oldStatus->canTransitionTo($newStatus)) {
throw new UnprocessableEntityHttpException(sprintf(
'Transition from "%s" to "%s" is not allowed.',
$oldStatus->value,
$newStatus->value,
));
}
if (ClientTicketStatus::Rejected === $newStatus && '' === trim((string) $data->getStatusComment())) {
throw new UnprocessableEntityHttpException('A status comment is required to reject a ticket.');
}
$data->setUpdatedAt(new DateTimeImmutable());
$this->entityManager->persist($data);
$this->entityManager->flush();
return $data;
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Infrastructure\Doctrine;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Module\ClientPortal\Domain\Repository\ClientTicketRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ClientTicket>
*/
final class DoctrineClientTicketRepository extends ServiceEntityRepository implements ClientTicketRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ClientTicket::class);
}
public function findById(int $id): ?ClientTicket
{
return $this->find($id);
}
public function findMaxNumberByProjectForUpdate(int $projectId): int
{
$conn = $this->getEntityManager()->getConnection();
// Use a PostgreSQL advisory lock (project ID as lock key) instead of
// FOR UPDATE because FOR UPDATE is not allowed with aggregate functions
// in PostgreSQL. The lock is held until the surrounding transaction ends.
$conn->executeStatement(
'SELECT pg_advisory_xact_lock(:project)',
['project' => $projectId],
);
$result = $conn->fetchOne(
'SELECT COALESCE(MAX(number), 0) FROM client_ticket WHERE project_id = :project',
['project' => $projectId],
);
return (int) $result;
}
}
+68 -2
View File
@@ -18,7 +18,9 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\LeaveProfileInterface;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -173,11 +175,34 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
#[Groups(['user:rbac:read', 'user:rbac:write'])]
private Collection $directPermissions;
// --- Client portal fields ---
/** Client this user belongs to. null = internal user, set = client user. */
#[ORM\ManyToOne(targetEntity: ClientInterface::class)]
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read', 'user:read', 'user:write'])]
private ?ClientInterface $client = null;
/**
* Projects a client user is allowed to access (a subset of the client's projects).
*
* @var Collection<int, ProjectInterface>
*/
#[ORM\ManyToMany(targetEntity: ProjectInterface::class)]
#[ORM\JoinTable(
name: 'user_allowed_projects',
joinColumns: [new ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'project_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
)]
#[Groups(['me:read', 'user:read', 'user:write'])]
private Collection $allowedProjects;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->rbacRoles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
$this->allowedProjects = new ArrayCollection();
}
public function getId(): ?int
@@ -229,8 +254,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
/** @return list<string> */
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
$roles = $this->roles;
// A client user must NOT inherit ROLE_USER (which would grant access to
// the internal application). Only non-client users get ROLE_USER.
if (!in_array('ROLE_CLIENT', $roles, true)) {
$roles[] = 'ROLE_USER';
}
return array_values(array_unique($roles));
}
@@ -454,6 +484,42 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
$this->directPermissions->removeElement($permission);
}
public function getClient(): ?ClientInterface
{
return $this->client;
}
public function setClient(?ClientInterface $client): static
{
$this->client = $client;
return $this;
}
/**
* @return Collection<int, ProjectInterface>
*/
public function getAllowedProjects(): Collection
{
return $this->allowedProjects;
}
public function addAllowedProject(ProjectInterface $project): static
{
if (!$this->allowedProjects->contains($project)) {
$this->allowedProjects->add($project);
}
return $this;
}
public function removeAllowedProject(ProjectInterface $project): static
{
$this->allowedProjects->removeElement($project);
return $this;
}
/**
* Permissions effectives = union (rôles RBAC → permissions) (permissions directes), triée, dédupliquée.
*
@@ -19,6 +19,7 @@ use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskCalendarPr
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskNumberProcessor;
use App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRepository;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\ClientTicketInterface;
use App\Shared\Domain\Contract\TaskInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
@@ -162,6 +163,16 @@ class Task implements TaskInterface, TimestampableInterface, BlamableInterface
#[Groups(['task:read', 'task:write'])]
private ?TaskRecurrence $recurrence = null;
/**
* Optional manual link to a client ticket. Exposed (number/type/status/title)
* in task:read so the kanban can show the linked-ticket icon without giving
* ROLE_USER access to the /api/client_tickets collection.
*/
#[ORM\ManyToOne(targetEntity: ClientTicketInterface::class)]
#[ORM\JoinColumn(name: 'client_ticket_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])]
private ?ClientTicketInterface $clientTicket = null;
public function __construct()
{
$this->tags = new ArrayCollection();
@@ -440,6 +451,18 @@ class Task implements TaskInterface, TimestampableInterface, BlamableInterface
return $this;
}
public function getClientTicket(): ?ClientTicketInterface
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicketInterface $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
#[Assert\Callback]
public function validateScheduledDates(ExecutionContextInterface $context): void
{
@@ -14,6 +14,7 @@ use ApiPlatform\Metadata\Post;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProcessor;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProvider;
use App\Module\ProjectManagement\Infrastructure\EventListener\TaskDocumentListener;
use App\Shared\Domain\Contract\ClientTicketInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
@@ -21,10 +22,10 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
new Get(security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')", provider: TaskDocumentProvider::class),
new Get(security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')", provider: TaskDocumentProvider::class),
new Post(
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')",
processor: TaskDocumentProcessor::class,
deserialize: false,
),
@@ -34,9 +35,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['task_document:write']],
order: ['id' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact'])]
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact', 'clientTicket' => 'exact'])]
#[ORM\Entity]
#[ORM\EntityListeners([TaskDocumentListener::class])]
// A document must be attached to either a task or a client ticket.
#[ORM\Table(name: 'task_document')]
class TaskDocument
{
#[ORM\Id]
@@ -46,10 +49,16 @@ class TaskDocument
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['task_document:read', 'task_document:write'])]
private ?Task $task = null;
/** Client ticket this document is attached to (alternative to task). */
#[ORM\ManyToOne(targetEntity: ClientTicketInterface::class)]
#[ORM\JoinColumn(name: 'client_ticket_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['task_document:read', 'task_document:write', 'client_ticket:read'])]
private ?ClientTicketInterface $clientTicket = null;
#[ORM\Column(length: 255)]
#[Groups(['task_document:read', 'task:read'])]
private ?string $originalName = null;
@@ -100,6 +109,18 @@ class TaskDocument
return $this;
}
public function getClientTicket(): ?ClientTicketInterface
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicketInterface $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
public function getOriginalName(): ?string
{
return $this->originalName;
@@ -14,6 +14,8 @@ use App\Module\Integration\Domain\Service\FileSource;
use App\Module\Integration\Domain\Service\SharePathResolver;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use App\Shared\Domain\Contract\ClientTicketInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -75,11 +77,12 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument
{
// Défense en profondeur : l'opération Post est déjà protégée par ROLE_ADMIN, mais on
// re-vérifie ici pour que les deux chemins (upload ET lien partage) restent sûrs si la
// configuration de sécurité de l'opération venait à changer.
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('Creating task documents requires admin privileges.');
// Défense en profondeur : l'opération Post est déjà protégée par
// ROLE_ADMIN ou ROLE_CLIENT, mais on re-vérifie ici pour que les deux
// chemins (upload ET lien partage) restent sûrs si la configuration de
// sécurité de l'opération venait à changer.
if (!$this->security->isGranted('ROLE_ADMIN') && !$this->security->isGranted('ROLE_CLIENT')) {
throw new AccessDeniedHttpException('Creating documents requires admin or client privileges.');
}
$request = $this->requestStack->getCurrentRequest();
@@ -136,8 +139,6 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
throw new BadRequestHttpException('File size exceeds 50 MB limit.');
}
$task = $this->resolveTask($request->request->get('task', ''));
// Use server-detected MIME type (finfo), not the client-supplied one
$originalName = $file->getClientOriginalName();
$mimeType = $file->getMimeType() ?: 'application/octet-stream';
@@ -157,7 +158,7 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
$file->move($this->uploadDir, $fileName);
$document = new TaskDocument();
$document->setTask($task);
$this->attachTarget($document, $request);
$document->setOriginalName($originalName);
$document->setFileName($fileName);
$document->setMimeType($mimeType);
@@ -168,15 +169,6 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
private function createShareLink(Request $request, string $rawSharePath): TaskDocument
{
$taskIri = $request->request->get('task');
if (!is_string($taskIri) || '' === $taskIri) {
$payload = json_decode($request->getContent() ?: '{}', true);
$taskIri = is_array($payload) ? ($payload['task'] ?? '') : '';
}
$task = $this->resolveTask((string) $taskIri);
try {
$path = $this->pathResolver->normalizeRelative($rawSharePath);
} catch (InvalidPathException) {
@@ -198,7 +190,7 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
}
$document = new TaskDocument();
$document->setTask($task);
$this->attachTarget($document, $request);
$document->setOriginalName($entry->name);
$document->setSharePath($path);
$document->setMimeType($entry->mimeType);
@@ -233,12 +225,61 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
return null;
}
private function resolveTask(string $taskIri): Task
/**
* Attaches the document to a task OR a client ticket, enforcing per-role
* access. Exactly one of the two targets must be provided.
*
* - ROLE_ADMIN may attach to any task or any client ticket.
* - ROLE_CLIENT may only attach to a client ticket they submitted, and may
* never attach to a task.
*/
private function attachTarget(TaskDocument $document, Request $request): void
{
if ('' === $taskIri) {
throw new BadRequestHttpException('A task IRI is required.');
$taskIri = $this->readField($request, 'task');
$clientTicketIri = $this->readField($request, 'clientTicket');
if ('' === $taskIri && '' === $clientTicketIri) {
throw new BadRequestHttpException('A task or a clientTicket IRI is required.');
}
if ('' !== $taskIri && '' !== $clientTicketIri) {
throw new BadRequestHttpException('Provide either a task or a clientTicket, not both.');
}
$isClient = $this->security->isGranted('ROLE_CLIENT') && !$this->security->isGranted('ROLE_ADMIN');
if ('' !== $clientTicketIri) {
$document->setClientTicket($this->resolveClientTicket($clientTicketIri, $isClient));
return;
}
if ($isClient) {
throw new AccessDeniedHttpException('Client users can only attach documents to a client ticket.');
}
$document->setTask($this->resolveTask($taskIri));
}
private function readField(Request $request, string $field): string
{
$value = $request->request->get($field);
if (is_string($value) && '' !== $value) {
return $value;
}
if (str_contains((string) $request->headers->get('Content-Type'), 'application/json')) {
$payload = json_decode($request->getContent() ?: '{}', true);
if (is_array($payload) && isset($payload[$field]) && is_string($payload[$field])) {
return $payload[$field];
}
}
return '';
}
private function resolveTask(string $taskIri): Task
{
$task = $this->entityManager->getRepository(Task::class)->find((int) basename($taskIri));
if (null === $task) {
@@ -247,4 +288,24 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
return $task;
}
private function resolveClientTicket(string $ticketIri, bool $isClient): ClientTicketInterface
{
$ticket = $this->entityManager->getRepository(ClientTicketInterface::class)->find((int) basename($ticketIri));
if (null === $ticket) {
throw new BadRequestHttpException('Client ticket not found.');
}
if ($isClient) {
$user = $this->security->getUser();
assert($user instanceof UserInterface);
if ($ticket->getSubmittedBy() !== $user) {
throw new AccessDeniedHttpException('You can only attach documents to your own tickets.');
}
}
return $ticket;
}
}
@@ -12,6 +12,12 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Provider for TaskDocument read operations.
*
* - ROLE_ADMIN: every document.
* - ROLE_USER: documents attached to a task (task IS NOT NULL).
* - ROLE_CLIENT: documents attached to a client ticket the user submitted.
*
* @implements ProviderInterface<TaskDocument>
*/
final readonly class TaskDocumentProvider implements ProviderInterface
@@ -26,25 +32,56 @@ final readonly class TaskDocumentProvider implements ProviderInterface
$user = $this->security->getUser();
assert($user instanceof UserInterface);
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
$isClient = $this->security->isGranted('ROLE_CLIENT');
$repo = $this->entityManager->getRepository(TaskDocument::class);
// Single item
// Single item.
if (isset($uriVariables['id'])) {
return $repo->find($uriVariables['id']);
$document = $repo->find($uriVariables['id']);
if (null === $document) {
return null;
}
if ($isAdmin) {
return $document;
}
if ($isClient) {
$ticket = $document->getClientTicket();
return null !== $ticket && $ticket->getSubmittedBy() === $user ? $document : null;
}
// ROLE_USER: task-linked documents only.
return null !== $document->getTask() ? $document : null;
}
// Collection
// Collection.
$qb = $repo->createQueryBuilder('d')
->orderBy('d.id', 'DESC')
;
// Apply filters from query parameters
if ($isClient && !$isAdmin) {
$qb->innerJoin('d.clientTicket', 'ct')
->andWhere('ct.submittedBy = :user')
->setParameter('user', $user)
;
} elseif (!$isAdmin) {
// ROLE_USER: only documents attached to a task.
$qb->andWhere('d.task IS NOT NULL');
}
$filters = $context['filters'] ?? [];
if (isset($filters['task'])) {
$qb->andWhere('d.task = :task')
->setParameter('task', self::extractId($filters['task']))
;
}
if (isset($filters['clientTicket'])) {
$qb->andWhere('d.clientTicket = :clientTicket')
->setParameter('clientTicket', self::extractId($filters['clientTicket']))
;
}
return $qb->getQuery()->getResult();
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat de LECTURE d'un ticket client, consommé hors du module ClientPortal.
*
* Permet à ProjectManagement (Task, TaskDocument) de référencer un ticket
* client sans dépendre directement de l'entité concrète du module ClientPortal.
*/
interface ClientTicketInterface
{
public function getId(): ?int;
public function getNumber(): ?int;
public function getType(): string;
public function getStatus(): string;
public function getTitle(): ?string;
}
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Shared\Domain\Contract;
use Doctrine\Common\Collections\Collection;
/**
* Contrat de LECTURE de l'identité, consommé hors du module Core.
* Les écritures (setPassword, setters HR…) restent sur le concret Core\Domain\Entity\User.
@@ -29,4 +31,16 @@ interface UserInterface
/** @return list<string> */
public function getEffectivePermissions(): array;
/**
* Client this user belongs to, or null for an internal user.
*/
public function getClient(): ?ClientInterface;
/**
* Projects a client user is allowed to access.
*
* @return Collection<int, ProjectInterface>
*/
public function getAllowedProjects(): Collection;
}
@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Module\ClientPortal;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
use App\Module\Core\Domain\Entity\User;
use App\Module\ProjectManagement\Domain\Entity\Project;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use function count;
/**
* Phase 1 security boundary: a pure ROLE_CLIENT user is walled off from the
* internal API but can reach its own client portal collection.
*
* @internal
*/
final class ClientTicketApiTest extends WebTestCase
{
public function testClientUserCannotListTasks(): void
{
$client = self::createClient();
$this->loginClient($client, 'client-liot');
$client->request('GET', '/api/tasks');
self::assertResponseStatusCodeSame(403);
}
public function testClientUserCannotListProjects(): void
{
$client = self::createClient();
$this->loginClient($client, 'client-liot');
$client->request('GET', '/api/projects');
self::assertResponseStatusCodeSame(403);
}
public function testClientUserCanListOwnClientTickets(): void
{
$client = self::createClient();
$this->loginClient($client, 'client-liot');
$client->request('GET', '/api/client_tickets');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('member', $data);
self::assertNotEmpty($data['member']);
// Tenancy invariant (robust to POST tests accumulating tickets in the
// shared test DB): client-liot sees its own SIRH ticket but NEVER the
// ticket submitted by another client (ACME, on the CRM project).
$titles = array_column($data['member'], 'title');
self::assertContains('Erreur lors de l\'export des congés', $titles);
self::assertNotContains('Ajouter un filtre par commercial', $titles);
}
public function testClientUserSeesOnlyOwnTickets(): void
{
$client = self::createClient();
$this->loginClient($client, 'client-acme');
$client->request('GET', '/api/client_tickets');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertNotEmpty($data['member']);
// Tenancy invariant: client-acme sees its own CRM ticket but NEVER the
// ticket submitted by client-liot (on the SIRH project).
$titles = array_column($data['member'], 'title');
self::assertContains('Ajouter un filtre par commercial', $titles);
self::assertNotContains('Erreur lors de l\'export des congés', $titles);
}
public function testInternalUserCannotListClientTickets(): void
{
$client = self::createClient();
$this->loginClient($client, 'alice');
$client->request('GET', '/api/client_tickets');
self::assertResponseStatusCodeSame(403);
}
public function testAdminCanListAllClientTickets(): void
{
$client = self::createClient();
$this->loginClient($client, 'admin');
$client->request('GET', '/api/client_tickets');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertGreaterThanOrEqual(2, count($data['member']));
}
public function testClientCanCreateTicketAndNumberIsGenerated(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$projectIri = $this->sirhProjectIri($em);
$this->loginClient($client, 'client-liot');
$client->request('POST', '/api/client_tickets', server: [
'CONTENT_TYPE' => 'application/ld+json',
], content: json_encode([
'type' => 'other',
'title' => 'Demande de fonctionnalité',
'description' => 'Une nouvelle option serait utile.',
'project' => $projectIri,
]));
self::assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
self::assertSame('new', $data['status']);
self::assertGreaterThanOrEqual(1, $data['number']);
}
public function testAdminCannotCreateTicket(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$projectIri = $this->sirhProjectIri($em);
$this->loginClient($client, 'admin');
$client->request('POST', '/api/client_tickets', server: [
'CONTENT_TYPE' => 'application/ld+json',
], content: json_encode([
'type' => 'other',
'title' => 'Admin attempt',
'description' => 'Should be forbidden.',
'project' => $projectIri,
]));
// Admin has no client, so ticket creation is denied.
self::assertResponseStatusCodeSame(403);
}
public function testRejectingTicketRequiresStatusComment(): void
{
$client = self::createClient();
$em = self::getContainer()->get(EntityManagerInterface::class);
$ticket = $em->getRepository(ClientTicket::class)
->findOneBy(['status' => ClientTicketStatus::New])
;
self::assertNotNull($ticket);
$id = $ticket->getId();
$this->loginClient($client, 'admin');
$client->request('PATCH', '/api/client_tickets/'.$id, server: [
'CONTENT_TYPE' => 'application/merge-patch+json',
], content: json_encode(['status' => 'rejected']));
self::assertResponseStatusCodeSame(422);
}
private function sirhProjectIri(EntityManagerInterface $em): string
{
$project = $em->getRepository(Project::class)
->findOneBy(['code' => 'SIRH'])
;
self::assertNotNull($project);
return '/api/projects/'.$project->getId();
}
private function loginClient(KernelBrowser $client, string $username): void
{
$em = self::getContainer()->get(EntityManagerInterface::class);
$user = $em->getRepository(User::class)->findOneBy(['username' => $username]);
self::assertInstanceOf(User::class, $user);
$client->loginUser($user);
}
}
@@ -6,11 +6,14 @@ namespace App\Tests\Unit\Shared\Doctrine;
use App\Shared\Application\CurrentUserProviderInterface;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use App\Shared\Infrastructure\Doctrine\TimestampableBlamableSubscriber;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use PHPUnit\Framework\TestCase;
use stdClass;
@@ -120,6 +123,16 @@ final class TimestampableBlamableSubscriberTest extends TestCase
{
return [];
}
public function getClient(): ?ClientInterface
{
return null;
}
public function getAllowedProjects(): Collection
{
return new ArrayCollection();
}
};
}