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:
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user