From c2fa308f1ef814035f79ce76c3a44f411c5a3729 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:08:27 +0100 Subject: [PATCH 01/89] docs : add MCP server design spec for Lesstime Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-03-15-mcp-server-design.md | 356 ++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-15-mcp-server-design.md diff --git a/docs/superpowers/specs/2026-03-15-mcp-server-design.md b/docs/superpowers/specs/2026-03-15-mcp-server-design.md new file mode 100644 index 0000000..0871003 --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-mcp-server-design.md @@ -0,0 +1,356 @@ +# MCP Server for Lesstime — Design Spec + +**Date**: 2026-03-15 +**Status**: Draft +**Scope**: Expose projects, tasks, and time tracking via MCP for AI clients (Claude Code local first) + +## Context + +Lesstime is a project management app (Symfony 8 + API Platform 4). We want AI assistants to interact with projects, tasks, and time entries via the Model Context Protocol (MCP). + +**Phase 1 (this spec)**: STDIO transport for Claude Code local usage. +**Phase 2 (future)**: HTTP transport + API token auth + Cloudflare Tunnel for remote clients (Claude Web, ChatGPT, Codex). + +## Technology Choice + +**`symfony/mcp-bundle`** — the official Symfony MCP bundle, maintained by Symfony + PHP Foundation + Anthropic. Uses PHP attributes (`#[McpTool]`) for auto-discovery. + +## Architecture + +### File Structure + +``` +src/Mcp/ +├── Tool/ +│ ├── Project/ +│ │ ├── ListProjectsTool.php +│ │ ├── GetProjectTool.php +│ │ └── CreateProjectTool.php +│ ├── Task/ +│ │ ├── ListTasksTool.php +│ │ ├── GetTaskTool.php +│ │ ├── CreateTaskTool.php +│ │ ├── UpdateTaskTool.php +│ │ └── DeleteTaskTool.php +│ ├── TaskMeta/ +│ │ ├── ListStatusesTool.php +│ │ ├── ListPrioritiesTool.php +│ │ ├── ListEffortsTool.php +│ │ ├── ListTagsTool.php +│ │ └── ListGroupsTool.php +│ └── TimeEntry/ +│ ├── ListTimeEntriesTool.php +│ ├── CreateTimeEntryTool.php +│ ├── UpdateTimeEntryTool.php +│ └── DeleteTimeEntryTool.php +``` + +### Configuration + +```yaml +# config/packages/mcp.yaml +mcp: + app: 'lesstime' + version: '1.0.0' + description: 'Lesstime project management — projects, tasks, time tracking' + instructions: | + This server provides access to the Lesstime project management system. + You can list/create/update/delete projects, tasks, and time entries. + Tasks belong to projects and have statuses, priorities, efforts, tags, and groups. + Time entries track work duration and can be linked to projects and tasks. + Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover + available metadata before creating or updating tasks. + client_transports: + stdio: true + http: false # Phase 2 +``` + +### Claude Code Configuration + +```json +// .claude/settings.json or project settings +{ + "mcpServers": { + "lesstime": { + "command": "docker", + "args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"], + "cwd": "/home/r-dev/Lesstime" + } + } +} +``` + +Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses `docker exec` to run inside the container. + +## Tools Specification + +### Project Tools + +#### `list-projects` +- **Description**: List all projects with optional archive filter +- **Parameters**: `archived` (bool, optional, default: false) +- **Returns**: Array of `{ id, code, name, description, color, client: { id, name } | null, archived }` +- **Implementation**: `ProjectRepository::findBy(['archived' => $archived], ['name' => 'ASC'])` + +#### `get-project` +- **Description**: Get project details with task count summary per status +- **Parameters**: `id` (int, required) +- **Returns**: `{ id, code, name, description, color, client, archived, taskSummary: { statusLabel: count, ... }, totalTasks }` +- **Implementation**: `ProjectRepository::find($id)` + DQL count query grouped by status + +#### `create-project` +- **Description**: Create a new project +- **Parameters**: `name` (string, required), `code` (string, required, 2-10 uppercase letters), `description` (string, optional), `color` (string, optional), `clientId` (int, optional) +- **Returns**: Created project object +- **Implementation**: Create `Project` entity, persist via `EntityManager` + +### Task Tools + +#### `list-tasks` +- **Description**: List tasks with filters +- **Parameters**: + - `projectId` (int, optional) — filter by project + - `statusId` (int, optional) — filter by status + - `assigneeId` (int, optional) — filter by assignee + - `priorityId` (int, optional) — filter by priority + - `groupId` (int, optional) — filter by group + - `tagIds` (int[], optional) — filter by tags + - `archived` (bool, optional, default: false) +- **Returns**: Array of `{ id, number, title, status: { id, label, color }, priority: { id, label, color } | null, assignee: { id, username } | null, effort: { id, label } | null, group: { id, title } | null, project: { id, code, name }, tags: [{ id, label }], archived }` +- **Implementation**: `TaskRepository` with QueryBuilder and conditional filters + +#### `get-task` +- **Description**: Get full task details +- **Parameters**: `id` (int, required) +- **Returns**: Full task object including `{ id, number, title, description, status, priority, effort, assignee, group, project, tags, documents: [{ id, originalName, mimeType, size }], archived }` +- **Implementation**: `TaskRepository::find($id)` with eager loading + +#### `create-task` +- **Description**: Create a new task (number auto-generated per project) +- **Parameters**: + - `projectId` (int, required) + - `title` (string, required) + - `description` (string, optional) + - `statusId` (int, optional) + - `priorityId` (int, optional) + - `effortId` (int, optional) + - `assigneeId` (int, optional) + - `groupId` (int, optional) + - `tagIds` (int[], optional) +- **Returns**: Created task with auto-generated number +- **Implementation**: Create `Task` entity, compute number via `TaskRepository::findMaxNumberByProject()` + 1, set relations, persist + +#### `update-task` +- **Description**: Update an existing task (partial update, only provided fields are changed) +- **Parameters**: + - `id` (int, required) + - `title` (string, optional) + - `description` (string, optional) + - `statusId` (int, optional) + - `priorityId` (int, optional) + - `effortId` (int, optional) + - `assigneeId` (int, optional) + - `groupId` (int, optional) + - `tagIds` (int[], optional) + - `archived` (bool, optional) +- **Returns**: Updated task object +- **Implementation**: Find task, apply changes, flush + +#### `delete-task` +- **Description**: Delete a task permanently +- **Parameters**: `id` (int, required) +- **Returns**: `{ success: true, message: "Task PROJECT-123 deleted" }` +- **Implementation**: `EntityManager::remove()` + flush (cascade deletes documents) + +### TaskMeta Tools (Read-Only) + +#### `list-statuses` +- **Description**: List all task statuses (needed to create/update tasks) +- **Returns**: Array of `{ id, label, color, position, isFinal }` +- **Implementation**: `TaskStatusRepository::findBy([], ['position' => 'ASC'])` + +#### `list-priorities` +- **Description**: List all task priorities +- **Returns**: Array of `{ id, label, color }` +- **Implementation**: `TaskPriorityRepository::findBy([], ['label' => 'ASC'])` + +#### `list-efforts` +- **Description**: List all task effort levels +- **Returns**: Array of `{ id, label }` +- **Implementation**: `TaskEffortRepository::findBy([], ['label' => 'ASC'])` + +#### `list-tags` +- **Description**: List all task tags +- **Returns**: Array of `{ id, label, color }` +- **Implementation**: `TaskTagRepository::findBy([], ['label' => 'ASC'])` + +#### `list-groups` +- **Description**: List task groups, optionally filtered by project +- **Parameters**: `projectId` (int, optional), `archived` (bool, optional, default: false) +- **Returns**: Array of `{ id, title, description, color, project: { id, code, name }, archived }` +- **Implementation**: `TaskGroupRepository` with optional project filter + +### TimeEntry Tools + +#### `list-time-entries` +- **Description**: List time entries with filters +- **Parameters**: + - `userId` (int, optional) + - `projectId` (int, optional) + - `startDate` (string, optional, format: YYYY-MM-DD) + - `endDate` (string, optional, format: YYYY-MM-DD) +- **Returns**: Array of `{ id, title, description, startedAt, stoppedAt, duration (minutes), user: { id, username }, project: { id, code, name } | null, task: { id, number, title } | null, tags: [{ id, label }] }` +- **Implementation**: `TimeEntryRepository` with QueryBuilder, date range filter on `startedAt` + +#### `create-time-entry` +- **Description**: Create a time entry +- **Parameters**: + - `title` (string, required) + - `startedAt` (string, required, ISO 8601) + - `stoppedAt` (string, optional, ISO 8601 — if null, creates active timer) + - `projectId` (int, optional) + - `taskId` (int, optional) + - `tagIds` (int[], optional) + - `description` (string, optional) + - `userId` (int, required) +- **Returns**: Created time entry +- **Implementation**: Create `TimeEntry`, set relations, persist. Validate no other active timer for user if stoppedAt is null. + +#### `update-time-entry` +- **Description**: Update a time entry (e.g., stop a running timer) +- **Parameters**: + - `id` (int, required) + - `title` (string, optional) + - `stoppedAt` (string, optional, ISO 8601) + - `projectId` (int, optional) + - `taskId` (int, optional) + - `tagIds` (int[], optional) + - `description` (string, optional) +- **Returns**: Updated time entry +- **Implementation**: Find entry, apply changes, flush + +#### `delete-time-entry` +- **Description**: Delete a time entry +- **Parameters**: `id` (int, required) +- **Returns**: `{ success: true, message: "Time entry deleted" }` +- **Implementation**: `EntityManager::remove()` + flush + +## Tool Return Format + +All tools return JSON strings. For consistency: + +- **List tools**: Return a JSON array of objects +- **Get/Create/Update tools**: Return a single JSON object +- **Delete tools**: Return `{ success: true, message: "..." }` +- **Errors**: Throw exceptions (the MCP bundle handles error responses) + +Example tool implementation pattern: + +```php +taskRepository->createQueryBuilder('t') + ->leftJoin('t.status', 's')->addSelect('s') + ->leftJoin('t.priority', 'p')->addSelect('p') + ->leftJoin('t.assignee', 'a')->addSelect('a') + ->leftJoin('t.project', 'pr')->addSelect('pr') + ->where('t.archived = :archived') + ->setParameter('archived', $archived) + ->orderBy('t.id', 'DESC'); + + if ($projectId !== null) { + $qb->andWhere('pr.id = :projectId')->setParameter('projectId', $projectId); + } + if ($statusId !== null) { + $qb->andWhere('s.id = :statusId')->setParameter('statusId', $statusId); + } + if ($assigneeId !== null) { + $qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId); + } + if ($priorityId !== null) { + $qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId); + } + if ($groupId !== null) { + $qb->andWhere('t.group = :groupId')->setParameter('groupId', $groupId); + } + + $tasks = $qb->getQuery()->getResult(); + + // Filter by tags in PHP (ManyToMany not easily filterable in DQL) + if ($tagIds !== null) { + $tasks = array_filter($tasks, function ($task) use ($tagIds) { + $taskTagIds = $task->getTags()->map(fn($t) => $t->getId())->toArray(); + return !empty(array_intersect($tagIds, $taskTagIds)); + }); + } + + return json_encode(array_map(fn($task) => [ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'status' => $task->getStatus() ? [ + 'id' => $task->getStatus()->getId(), + 'label' => $task->getStatus()->getLabel(), + ] : null, + 'priority' => $task->getPriority() ? [ + 'id' => $task->getPriority()->getId(), + 'label' => $task->getPriority()->getLabel(), + ] : null, + 'assignee' => $task->getAssignee() ? [ + 'id' => $task->getAssignee()->getId(), + 'username' => $task->getAssignee()->getUsername(), + ] : null, + 'project' => [ + 'id' => $task->getProject()->getId(), + 'code' => $task->getProject()->getCode(), + 'name' => $task->getProject()->getName(), + ], + 'archived' => $task->isArchived(), + ], array_values($tasks))); + } +} +``` + +## Installation Steps + +1. `composer require symfony/mcp-bundle` (inside Docker container) +2. Create `config/packages/mcp.yaml` with STDIO transport +3. Add MCP route: `config/routes/mcp.yaml` +4. Create tool classes in `src/Mcp/Tool/` +5. Test with `php bin/console mcp:server` (STDIO) +6. Configure Claude Code settings to point to the MCP server + +## Phase 2 (Future) + +When ready for remote clients: + +1. Enable HTTP transport on `/_mcp` +2. Add `apiToken` field on `User` entity + migration +3. Create `ApiTokenAuthenticator` (Symfony custom authenticator) +4. Add firewall rule for `/_mcp` path +5. Set up Cloudflare Tunnel for external access +6. Configure Claude Web / ChatGPT / Codex with the tunnel URL + token From 8d2494918639d5c3c98802abf75a57981723c638 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:11:02 +0100 Subject: [PATCH 02/89] docs : update MCP server spec with review fixes Adds list-users, list-clients, update-project tools. Fixes time entry title as optional, adds startedAt to update-time-entry, adds taskId filter, pagination limits, eager joins, security model docs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-03-15-mcp-server-design.md | 120 ++++++++++++++---- 1 file changed, 95 insertions(+), 25 deletions(-) diff --git a/docs/superpowers/specs/2026-03-15-mcp-server-design.md b/docs/superpowers/specs/2026-03-15-mcp-server-design.md index 0871003..5ebdc91 100644 --- a/docs/superpowers/specs/2026-03-15-mcp-server-design.md +++ b/docs/superpowers/specs/2026-03-15-mcp-server-design.md @@ -25,7 +25,8 @@ src/Mcp/ │ ├── Project/ │ │ ├── ListProjectsTool.php │ │ ├── GetProjectTool.php -│ │ └── CreateProjectTool.php +│ │ ├── CreateProjectTool.php +│ │ └── UpdateProjectTool.php │ ├── Task/ │ │ ├── ListTasksTool.php │ │ ├── GetTaskTool.php @@ -38,11 +39,14 @@ src/Mcp/ │ │ ├── ListEffortsTool.php │ │ ├── ListTagsTool.php │ │ └── ListGroupsTool.php -│ └── TimeEntry/ -│ ├── ListTimeEntriesTool.php -│ ├── CreateTimeEntryTool.php -│ ├── UpdateTimeEntryTool.php -│ └── DeleteTimeEntryTool.php +│ ├── TimeEntry/ +│ │ ├── ListTimeEntriesTool.php +│ │ ├── CreateTimeEntryTool.php +│ │ ├── UpdateTimeEntryTool.php +│ │ └── DeleteTimeEntryTool.php +│ └── Reference/ +│ ├── ListUsersTool.php +│ └── ListClientsTool.php ``` ### Configuration @@ -57,9 +61,12 @@ mcp: This server provides access to the Lesstime project management system. You can list/create/update/delete projects, tasks, and time entries. Tasks belong to projects and have statuses, priorities, efforts, tags, and groups. + Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects). + Groups are PER-PROJECT (each group belongs to one project). Time entries track work duration and can be linked to projects and tasks. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover available metadata before creating or updating tasks. + Use list-users and list-clients to discover valid user and client IDs. client_transports: stdio: true http: false # Phase 2 @@ -82,8 +89,26 @@ mcp: Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses `docker exec` to run inside the container. +### Security Model (Phase 1) + +Phase 1 uses STDIO transport only (Claude Code local). The console command runs without a Symfony security context. All tools run with **full privileges** (equivalent to ROLE_ADMIN), since only the local developer has access. No authentication is needed. + +Phase 2 will add API token authentication on the HTTP transport. + ## Tools Specification +### Reference Tools (ID Discovery) + +#### `list-users` +- **Description**: List all users (needed to resolve assignee/user IDs) +- **Returns**: Array of `{ id, username }` +- **Implementation**: `UserRepository::findBy([], ['username' => 'ASC'])` + +#### `list-clients` +- **Description**: List all clients (needed to resolve client IDs for projects) +- **Returns**: Array of `{ id, name, email }` +- **Implementation**: `ClientRepository::findBy([], ['name' => 'ASC'])` + ### Project Tools #### `list-projects` @@ -104,10 +129,23 @@ Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses - **Returns**: Created project object - **Implementation**: Create `Project` entity, persist via `EntityManager` +#### `update-project` +- **Description**: Update an existing project (partial update) +- **Parameters**: + - `id` (int, required) + - `name` (string, optional) + - `code` (string, optional) + - `description` (string, optional) + - `color` (string, optional) + - `clientId` (int, optional) + - `archived` (bool, optional) +- **Returns**: Updated project object +- **Implementation**: Find project, apply changes, flush + ### Task Tools #### `list-tasks` -- **Description**: List tasks with filters +- **Description**: List tasks with filters. Returns max 100 results, use filters to narrow down. - **Parameters**: - `projectId` (int, optional) — filter by project - `statusId` (int, optional) — filter by status @@ -116,13 +154,14 @@ Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses - `groupId` (int, optional) — filter by group - `tagIds` (int[], optional) — filter by tags - `archived` (bool, optional, default: false) + - `limit` (int, optional, default: 100, max: 200) - **Returns**: Array of `{ id, number, title, status: { id, label, color }, priority: { id, label, color } | null, assignee: { id, username } | null, effort: { id, label } | null, group: { id, title } | null, project: { id, code, name }, tags: [{ id, label }], archived }` -- **Implementation**: `TaskRepository` with QueryBuilder and conditional filters +- **Implementation**: `TaskRepository` with QueryBuilder, conditional filters, and `setMaxResults($limit)`. Joins must include all relations: status, priority, assignee, project, effort, group, tags. #### `get-task` - **Description**: Get full task details - **Parameters**: `id` (int, required) -- **Returns**: Full task object including `{ id, number, title, description, status, priority, effort, assignee, group, project, tags, documents: [{ id, originalName, mimeType, size }], archived }` +- **Returns**: Full task object including `{ id, number, title, description, status, priority, effort, assignee, group, project, tags, documents: [{ id, originalName, mimeType, size, createdAt, uploadedBy: { id, username } }], archived }` - **Implementation**: `TaskRepository::find($id)` with eager loading #### `create-task` @@ -138,7 +177,7 @@ Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses - `groupId` (int, optional) - `tagIds` (int[], optional) - **Returns**: Created task with auto-generated number -- **Implementation**: Create `Task` entity, compute number via `TaskRepository::findMaxNumberByProject()` + 1, set relations, persist +- **Implementation**: Create `Task` entity, reuse `TaskRepository::findMaxNumberByProject()` for number generation (same logic as `TaskNumberProcessor`), set relations, persist #### `update-task` - **Description**: Update an existing task (partial update, only provided fields are changed) @@ -164,6 +203,8 @@ Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses ### TaskMeta Tools (Read-Only) +Statuses, priorities, efforts, and tags are **global** (shared across all projects). Groups are **per-project**. + #### `list-statuses` - **Description**: List all task statuses (needed to create/update tasks) - **Returns**: Array of `{ id, label, color, position, isFinal }` @@ -185,7 +226,7 @@ Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses - **Implementation**: `TaskTagRepository::findBy([], ['label' => 'ASC'])` #### `list-groups` -- **Description**: List task groups, optionally filtered by project +- **Description**: List task groups, optionally filtered by project. Groups are per-project. - **Parameters**: `projectId` (int, optional), `archived` (bool, optional, default: false) - **Returns**: Array of `{ id, title, description, color, project: { id, code, name }, archived }` - **Implementation**: `TaskGroupRepository` with optional project filter @@ -197,36 +238,41 @@ Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses - **Parameters**: - `userId` (int, optional) - `projectId` (int, optional) + - `taskId` (int, optional) - `startDate` (string, optional, format: YYYY-MM-DD) - `endDate` (string, optional, format: YYYY-MM-DD) -- **Returns**: Array of `{ id, title, description, startedAt, stoppedAt, duration (minutes), user: { id, username }, project: { id, code, name } | null, task: { id, number, title } | null, tags: [{ id, label }] }` + - `limit` (int, optional, default: 100, max: 200) +- **Returns**: Array of `{ id, title, description, startedAt, stoppedAt, duration, user: { id, username }, project: { id, code, name } | null, task: { id, number, title } | null, tags: [{ id, label }] }` +- **Note**: `duration` is computed from `stoppedAt - startedAt` in minutes. Returns `null` for active timers (stoppedAt is null). - **Implementation**: `TimeEntryRepository` with QueryBuilder, date range filter on `startedAt` #### `create-time-entry` - **Description**: Create a time entry - **Parameters**: - - `title` (string, required) + - `userId` (int, required) - `startedAt` (string, required, ISO 8601) + - `title` (string, optional) - `stoppedAt` (string, optional, ISO 8601 — if null, creates active timer) - `projectId` (int, optional) - `taskId` (int, optional) - `tagIds` (int[], optional) - `description` (string, optional) - - `userId` (int, required) - **Returns**: Created time entry - **Implementation**: Create `TimeEntry`, set relations, persist. Validate no other active timer for user if stoppedAt is null. #### `update-time-entry` -- **Description**: Update a time entry (e.g., stop a running timer) +- **Description**: Update a time entry (e.g., stop a running timer, correct start time) - **Parameters**: - `id` (int, required) - `title` (string, optional) + - `startedAt` (string, optional, ISO 8601) - `stoppedAt` (string, optional, ISO 8601) - `projectId` (int, optional) - `taskId` (int, optional) - `tagIds` (int[], optional) - `description` (string, optional) - **Returns**: Updated time entry +- **Note**: `userId` is intentionally not updatable via MCP. Reassigning time entries to another user should be done through the app UI. - **Implementation**: Find entry, apply changes, flush #### `delete-time-entry` @@ -243,6 +289,7 @@ All tools return JSON strings. For consistency: - **Get/Create/Update tools**: Return a single JSON object - **Delete tools**: Return `{ success: true, message: "..." }` - **Errors**: Throw exceptions (the MCP bundle handles error responses) +- **Duration**: Computed field (minutes), `null` for active timers Example tool implementation pattern: @@ -272,15 +319,22 @@ class ListTasksTool ?int $groupId = null, ?array $tagIds = null, bool $archived = false, + int $limit = 100, ): string { + $limit = min($limit, 200); + $qb = $this->taskRepository->createQueryBuilder('t') ->leftJoin('t.status', 's')->addSelect('s') ->leftJoin('t.priority', 'p')->addSelect('p') ->leftJoin('t.assignee', 'a')->addSelect('a') ->leftJoin('t.project', 'pr')->addSelect('pr') + ->leftJoin('t.effort', 'e')->addSelect('e') + ->leftJoin('t.group', 'g')->addSelect('g') + ->leftJoin('t.tags', 'tg')->addSelect('tg') ->where('t.archived = :archived') ->setParameter('archived', $archived) - ->orderBy('t.id', 'DESC'); + ->orderBy('t.id', 'DESC') + ->setMaxResults($limit); if ($projectId !== null) { $qb->andWhere('pr.id = :projectId')->setParameter('projectId', $projectId); @@ -315,20 +369,34 @@ class ListTasksTool 'status' => $task->getStatus() ? [ 'id' => $task->getStatus()->getId(), 'label' => $task->getStatus()->getLabel(), + 'color' => $task->getStatus()->getColor(), ] : null, 'priority' => $task->getPriority() ? [ 'id' => $task->getPriority()->getId(), 'label' => $task->getPriority()->getLabel(), + 'color' => $task->getPriority()->getColor(), ] : null, 'assignee' => $task->getAssignee() ? [ 'id' => $task->getAssignee()->getId(), 'username' => $task->getAssignee()->getUsername(), ] : null, + 'effort' => $task->getEffort() ? [ + 'id' => $task->getEffort()->getId(), + 'label' => $task->getEffort()->getLabel(), + ] : null, + 'group' => $task->getGroup() ? [ + 'id' => $task->getGroup()->getId(), + 'title' => $task->getGroup()->getTitle(), + ] : null, 'project' => [ 'id' => $task->getProject()->getId(), 'code' => $task->getProject()->getCode(), 'name' => $task->getProject()->getName(), ], + 'tags' => $task->getTags()->map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), 'archived' => $task->isArchived(), ], array_values($tasks))); } @@ -339,18 +407,20 @@ class ListTasksTool 1. `composer require symfony/mcp-bundle` (inside Docker container) 2. Create `config/packages/mcp.yaml` with STDIO transport -3. Add MCP route: `config/routes/mcp.yaml` -4. Create tool classes in `src/Mcp/Tool/` -5. Test with `php bin/console mcp:server` (STDIO) -6. Configure Claude Code settings to point to the MCP server +3. Create tool classes in `src/Mcp/Tool/` +4. Test with `php bin/console mcp:server` (STDIO) +5. Configure Claude Code settings to point to the MCP server + +Note: STDIO transport does not need HTTP routes. Routes are only needed for Phase 2 (HTTP transport). ## Phase 2 (Future) When ready for remote clients: 1. Enable HTTP transport on `/_mcp` -2. Add `apiToken` field on `User` entity + migration -3. Create `ApiTokenAuthenticator` (Symfony custom authenticator) -4. Add firewall rule for `/_mcp` path -5. Set up Cloudflare Tunnel for external access -6. Configure Claude Web / ChatGPT / Codex with the tunnel URL + token +2. Add MCP route: `config/routes/mcp.yaml` +3. Add `apiToken` field on `User` entity + migration +4. Create `ApiTokenAuthenticator` (Symfony custom authenticator) +5. Add firewall rule for `/_mcp` path +6. Set up Cloudflare Tunnel for external access +7. Configure Claude Web / ChatGPT / Codex with the tunnel URL + token From 9e19adc09a51bbab610ca15270e09fca4c5b63e6 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:15:20 +0100 Subject: [PATCH 03/89] docs : add HTTP transport + API token auth to MCP spec Both STDIO (local) and HTTP (LAN) transports are now in scope. HTTP secured by API token on User entity with custom authenticator. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-03-15-mcp-server-design.md | 92 ++++++++++++++----- 1 file changed, 69 insertions(+), 23 deletions(-) diff --git a/docs/superpowers/specs/2026-03-15-mcp-server-design.md b/docs/superpowers/specs/2026-03-15-mcp-server-design.md index 5ebdc91..81ae6c1 100644 --- a/docs/superpowers/specs/2026-03-15-mcp-server-design.md +++ b/docs/superpowers/specs/2026-03-15-mcp-server-design.md @@ -8,8 +8,11 @@ Lesstime is a project management app (Symfony 8 + API Platform 4). We want AI assistants to interact with projects, tasks, and time entries via the Model Context Protocol (MCP). -**Phase 1 (this spec)**: STDIO transport for Claude Code local usage. -**Phase 2 (future)**: HTTP transport + API token auth + Cloudflare Tunnel for remote clients (Claude Web, ChatGPT, Codex). +Both transports are implemented together: +- **STDIO**: Claude Code on the same machine (local dev, `php bin/console mcp:server`) +- **HTTP**: Claude Code or any MCP client on the LAN (`http://:8082/_mcp`), secured by API token + +Future: Cloudflare Tunnel for internet-facing access (Claude Web, ChatGPT, Codex). ## Technology Choice @@ -69,13 +72,31 @@ mcp: Use list-users and list-clients to discover valid user and client IDs. client_transports: stdio: true - http: false # Phase 2 + http: true + + http: + path: /_mcp + session: + store: file + directory: '%kernel.cache_dir%/mcp-sessions' + ttl: 3600 +``` + +### Nginx Configuration + +Add a location block to pass `/_mcp` requests to Symfony (same pattern as `/api`): + +```nginx +location /_mcp { + try_files $uri /index.php$is_args$args; +} ``` ### Claude Code Configuration +**Option A — Local (STDIO, same machine):** + ```json -// .claude/settings.json or project settings { "mcpServers": { "lesstime": { @@ -87,13 +108,38 @@ mcp: } ``` -Note: The app runs in Docker (`php-lesstime-fpm` container), so the command uses `docker exec` to run inside the container. +**Option B — Network (HTTP, another machine on LAN):** -### Security Model (Phase 1) +```json +{ + "mcpServers": { + "lesstime": { + "type": "url", + "url": "http://192.168.x.x:8082/_mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` -Phase 1 uses STDIO transport only (Claude Code local). The console command runs without a Symfony security context. All tools run with **full privileges** (equivalent to ROLE_ADMIN), since only the local developer has access. No authentication is needed. +### Security Model -Phase 2 will add API token authentication on the HTTP transport. +**STDIO transport**: No authentication. The console command runs locally with full privileges (equivalent to ROLE_ADMIN). Only the local developer has access. + +**HTTP transport**: Secured by API token. A new `apiToken` field on the `User` entity stores a unique token per user. A custom Symfony authenticator (`ApiTokenAuthenticator`) checks the `Authorization: Bearer ` header on `/_mcp` requests and authenticates as the corresponding user. + +#### API Token Implementation + +1. **Entity change**: Add `apiToken` (string, unique, nullable) to `User` + Doctrine migration +2. **Authenticator**: `src/Security/ApiTokenAuthenticator.php` — a Symfony custom authenticator that: + - Extracts the token from the `Authorization` header + - Looks up the user by `apiToken` + - Returns 401 if token missing/invalid +3. **Firewall**: New firewall entry in `config/packages/security.yaml` for `/_mcp` path, before the main `api` firewall +4. **Token generation**: A console command `app:generate-api-token ` to generate/regenerate tokens +5. **Fixtures**: Add an API token to the admin fixture user for dev/testing ## Tools Specification @@ -406,21 +452,21 @@ class ListTasksTool ## Installation Steps 1. `composer require symfony/mcp-bundle` (inside Docker container) -2. Create `config/packages/mcp.yaml` with STDIO transport -3. Create tool classes in `src/Mcp/Tool/` -4. Test with `php bin/console mcp:server` (STDIO) -5. Configure Claude Code settings to point to the MCP server +2. Create `config/packages/mcp.yaml` with STDIO + HTTP transports +3. Add MCP route: `config/routes/mcp.yaml` +4. Add Nginx location block for `/_mcp` +5. Add `apiToken` field to `User` entity + migration +6. Create `ApiTokenAuthenticator` + security firewall for `/_mcp` +7. Create `app:generate-api-token` console command +8. Update fixtures with API token for admin user +9. Create tool classes in `src/Mcp/Tool/` +10. Test STDIO: `php bin/console mcp:server` +11. Test HTTP: `curl -H "Authorization: Bearer " http://localhost:8082/_mcp` +12. Configure Claude Code settings (STDIO local or HTTP network) -Note: STDIO transport does not need HTTP routes. Routes are only needed for Phase 2 (HTTP transport). +## Future -## Phase 2 (Future) +When ready for internet-facing access: -When ready for remote clients: - -1. Enable HTTP transport on `/_mcp` -2. Add MCP route: `config/routes/mcp.yaml` -3. Add `apiToken` field on `User` entity + migration -4. Create `ApiTokenAuthenticator` (Symfony custom authenticator) -5. Add firewall rule for `/_mcp` path -6. Set up Cloudflare Tunnel for external access -7. Configure Claude Web / ChatGPT / Codex with the tunnel URL + token +1. Set up Cloudflare Tunnel for external access +2. Configure Claude Web / ChatGPT / Codex with the tunnel URL + token From 5547c67b30c36497d3c168222d9b9754c84228df Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:17:44 +0100 Subject: [PATCH 04/89] docs : add create-group and update-group tools to MCP spec Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-03-15-mcp-server-design.md | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-03-15-mcp-server-design.md b/docs/superpowers/specs/2026-03-15-mcp-server-design.md index 81ae6c1..440c8c9 100644 --- a/docs/superpowers/specs/2026-03-15-mcp-server-design.md +++ b/docs/superpowers/specs/2026-03-15-mcp-server-design.md @@ -41,7 +41,9 @@ src/Mcp/ │ │ ├── ListPrioritiesTool.php │ │ ├── ListEffortsTool.php │ │ ├── ListTagsTool.php -│ │ └── ListGroupsTool.php +│ │ ├── ListGroupsTool.php +│ │ ├── CreateGroupTool.php +│ │ └── UpdateGroupTool.php │ ├── TimeEntry/ │ │ ├── ListTimeEntriesTool.php │ │ ├── CreateTimeEntryTool.php @@ -247,9 +249,9 @@ location /_mcp { - **Returns**: `{ success: true, message: "Task PROJECT-123 deleted" }` - **Implementation**: `EntityManager::remove()` + flush (cascade deletes documents) -### TaskMeta Tools (Read-Only) +### TaskMeta Tools -Statuses, priorities, efforts, and tags are **global** (shared across all projects). Groups are **per-project**. +Statuses, priorities, efforts, and tags are **global** (shared across all projects, read-only via MCP). Groups are **per-project** (read/create/update). #### `list-statuses` - **Description**: List all task statuses (needed to create/update tasks) @@ -277,6 +279,27 @@ Statuses, priorities, efforts, and tags are **global** (shared across all projec - **Returns**: Array of `{ id, title, description, color, project: { id, code, name }, archived }` - **Implementation**: `TaskGroupRepository` with optional project filter +#### `create-group` +- **Description**: Create a new task group for a project +- **Parameters**: + - `projectId` (int, required) + - `title` (string, required) + - `description` (string, optional) + - `color` (string, optional, default: #222783) +- **Returns**: Created group object +- **Implementation**: Create `TaskGroup` entity, set project relation, persist + +#### `update-group` +- **Description**: Update an existing task group (partial update) +- **Parameters**: + - `id` (int, required) + - `title` (string, optional) + - `description` (string, optional) + - `color` (string, optional) + - `archived` (bool, optional) +- **Returns**: Updated group object +- **Implementation**: Find group, apply changes, flush + ### TimeEntry Tools #### `list-time-entries` From f4eec2e6e94fe8d61da8c5f9122b3378eb391747 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:18:25 +0100 Subject: [PATCH 05/89] docs : add client portal implementation plans (phases 1-3) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-15-client-portal-phase1.md | 1585 +++++++++++++ .../plans/2026-03-15-client-portal-phase2.md | 1960 +++++++++++++++++ .../plans/2026-03-15-client-portal-phase3.md | 970 ++++++++ 3 files changed, 4515 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-15-client-portal-phase1.md create mode 100644 docs/superpowers/plans/2026-03-15-client-portal-phase2.md create mode 100644 docs/superpowers/plans/2026-03-15-client-portal-phase3.md diff --git a/docs/superpowers/plans/2026-03-15-client-portal-phase1.md b/docs/superpowers/plans/2026-03-15-client-portal-phase1.md new file mode 100644 index 0000000..8b159d9 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-client-portal-phase1.md @@ -0,0 +1,1585 @@ +# Client Portal Phase 1 — Foundations + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Lay the backend and frontend foundations for the Client Portal feature: secure existing endpoints against `ROLE_CLIENT`, create the `ClientTicket` entity with full CRUD API, extend `User` with client/project assignments, generalize `TaskDocument` for ticket attachments, and update the admin user management form. + +**Architecture:** New `ROLE_CLIENT` role with isolated access. `ClientTicket` is a separate entity from `Task` with its own lifecycle. `TaskDocument` is generalized to support both tasks and tickets. Provider-based row-level security ensures clients only see their own tickets on their allowed projects. + +**Tech Stack:** PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16, Nuxt 4, Vue 3, TypeScript + +**Spec:** `docs/superpowers/specs/2026-03-15-client-portal-design.md` + +--- + +## Chunk 1: Security Hardening (Prerequisite) + +### Task 1: Fix User::getRoles() to exclude ROLE_USER for client users + +- [ ] **Modify `src/Entity/User.php`** — Change `getRoles()` at line 96 so that `ROLE_USER` is NOT added when the user has `ROLE_CLIENT`: + +Replace the existing method (lines 95-102): +```php + /** @return list */ + public function getRoles(): array + { + $roles = $this->roles; + $roles[] = 'ROLE_USER'; + + return array_values(array_unique($roles)); + } +``` + +With: +```php + /** @return list */ + public function getRoles(): array + { + $roles = $this->roles; + + if (!in_array('ROLE_CLIENT', $roles, true)) { + $roles[] = 'ROLE_USER'; + } + + return array_values(array_unique($roles)); + } +``` + +- [ ] **Commit:** +```bash +git add src/Entity/User.php +git commit -m "fix(security) : exclude ROLE_USER from ROLE_CLIENT users in getRoles()" +``` + +### Task 2: Add security on GetCollection/Get for Task and Project + +- [ ] **Modify `src/Entity/Task.php`** — Add `security` to GetCollection (line 25) and Get (line 26). Replace: +```php + new GetCollection(paginationEnabled: false), + new Get(), +``` +With: +```php + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), +``` + +- [ ] **Modify `src/Entity/Project.php`** — Add `security` to GetCollection (line 23) and Get (line 24). Replace: +```php + new GetCollection(), + new Get(), +``` +With: +```php + new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), +``` + +- [ ] **Commit:** +```bash +git add src/Entity/Task.php src/Entity/Project.php +git commit -m "fix(security) : add ROLE_USER security on Task and Project read operations" +``` + +### Task 3: Add security on GetCollection/Get for Client, TaskStatus, TaskEffort, TaskPriority + +- [ ] **Modify `src/Entity/Client.php`** — Replace (lines 21-22): +```php + new GetCollection(), + new Get(), +``` +With: +```php + new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), +``` + +- [ ] **Modify `src/Entity/TaskStatus.php`** — Replace (lines 19-20): +```php + new GetCollection(), + new Get(), +``` +With: +```php + new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), +``` + +- [ ] **Modify `src/Entity/TaskEffort.php`** — Replace (lines 19-20): +```php + new GetCollection(), + new Get(), +``` +With: +```php + new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), +``` + +- [ ] **Modify `src/Entity/TaskPriority.php`** — Replace (lines 19-20): +```php + new GetCollection(), + new Get(), +``` +With: +```php + new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), +``` + +- [ ] **Commit:** +```bash +git add src/Entity/Client.php src/Entity/TaskStatus.php src/Entity/TaskEffort.php src/Entity/TaskPriority.php +git commit -m "fix(security) : add ROLE_USER security on Client, TaskStatus, TaskEffort, TaskPriority read operations" +``` + +### Task 4: Add security on GetCollection/Get for TaskTag, TaskGroup, TimeEntry, TaskDocument + +- [ ] **Modify `src/Entity/TaskTag.php`** — Replace (lines 19-20): +```php + new GetCollection(), + new Get(), +``` +With: +```php + new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), +``` + +- [ ] **Modify `src/Entity/TaskGroup.php`** — Replace (lines 21-22): +```php + new GetCollection(), + new Get(), +``` +With: +```php + new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), +``` + +- [ ] **Modify `src/Entity/TimeEntry.php`** — Replace the first GetCollection (line 27) and Get (line 35): +```php + new GetCollection(), +``` +With: +```php + new GetCollection(security: "is_granted('ROLE_USER')"), +``` +And: +```php + new Get(), +``` +With: +```php + new Get(security: "is_granted('ROLE_USER')"), +``` + +- [ ] **Modify `src/Entity/TaskDocument.php`** — Replace (lines 22-23): +```php + new GetCollection(paginationEnabled: false), + new Get(), +``` +With: +```php + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), +``` + +- [ ] **Commit:** +```bash +git add src/Entity/TaskTag.php src/Entity/TaskGroup.php src/Entity/TimeEntry.php src/Entity/TaskDocument.php +git commit -m "fix(security) : add ROLE_USER security on TaskTag, TaskGroup, TimeEntry, TaskDocument read operations" +``` + +### Task 5: Add role hierarchy to security config + +- [ ] **Modify `config/packages/security.yaml`** — Add role hierarchy after the `password_hashers` block (after line 4). Replace: +```yaml +security: + # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + + # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider + providers: +``` +With: +```yaml +security: + # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords + password_hashers: + Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' + + role_hierarchy: + ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT] + + # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider + providers: +``` + +- [ ] **Commit:** +```bash +git add config/packages/security.yaml +git commit -m "feat(security) : add role hierarchy with ROLE_ADMIN inheriting ROLE_USER and ROLE_CLIENT" +``` + +--- + +## Chunk 2: Entity Modifications + +### Task 6: Extend User entity with client and allowedProjects fields + +- [ ] **Modify `src/Entity/User.php`** — Add two imports after the existing `use` statements (after line 20): +```php +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +``` + +- [ ] **Add the `client` and `allowedProjects` properties** after the `$password` property (after line 63): +```php + #[ORM\ManyToOne(targetEntity: Client::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + #[Groups(['me:read', 'user:list', 'user:write'])] + private ?Client $client = null; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: Project::class)] + #[ORM\JoinTable(name: 'user_allowed_projects')] + #[Groups(['me:read', 'user:list', 'user:write'])] + private Collection $allowedProjects; +``` + +- [ ] **Update the constructor** (line 68-71). Replace: +```php + public function __construct() + { + $this->createdAt = new DateTimeImmutable(); + } +``` +With: +```php + public function __construct() + { + $this->createdAt = new DateTimeImmutable(); + $this->allowedProjects = new ArrayCollection(); + } +``` + +- [ ] **Add getters and setters** before the `eraseCredentials()` method (before line 136): +```php + public function getClient(): ?Client + { + return $this->client; + } + + public function setClient(?Client $client): static + { + $this->client = $client; + + return $this; + } + + /** @return Collection */ + public function getAllowedProjects(): Collection + { + return $this->allowedProjects; + } + + public function addAllowedProject(Project $project): static + { + if (!$this->allowedProjects->contains($project)) { + $this->allowedProjects->add($project); + } + + return $this; + } + + public function removeAllowedProject(Project $project): static + { + $this->allowedProjects->removeElement($project); + + return $this; + } +``` + +- [ ] **Add `client_ticket:read` to User's `id` and `username` Groups** so that the `submittedBy` relation on ClientTicket embeds user data instead of a plain IRI. Find the existing `$id` and `$username` properties and update their Groups: + +Replace the existing `$id` Groups: +```php + #[Groups(['user:list', 'me:read'])] + private ?int $id = null; +``` +With: +```php + #[Groups(['user:list', 'me:read', 'client_ticket:read'])] + private ?int $id = null; +``` + +Replace the existing `$username` Groups: +```php + #[Groups(['user:list', 'me:read'])] + private ?string $username = null; +``` +With: +```php + #[Groups(['user:list', 'me:read', 'client_ticket:read'])] + private ?string $username = null; +``` + +- [ ] **Commit:** +```bash +git add src/Entity/User.php +git commit -m "feat(entity) : add client and allowedProjects fields to User entity" +``` + +### Task 7: Create ClientTicket entity + +- [ ] **Create `src/Entity/ClientTicket.php`** with the following complete content: +```php + ['client_ticket:read']], + denormalizationContext: ['groups' => ['client_ticket:write']], + order: ['createdAt' => 'DESC'], +)] +#[ORM\Entity(repositoryClass: ClientTicketRepository::class)] +#[ORM\Table( + uniqueConstraints: [ + new ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number']), + ], +)] +class ClientTicket +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['client_ticket:read', 'task:read'])] + private ?int $id = null; + + #[ORM\Column(type: 'integer')] + #[Groups(['client_ticket:read', 'task:read'])] + private ?int $number = null; + + #[ORM\Column(length: 20)] + #[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])] + private ?string $type = null; + + #[ORM\Column(length: 255)] + #[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])] + private ?string $title = null; + + #[ORM\Column(type: Types::TEXT)] + #[Groups(['client_ticket:read', 'client_ticket:write'])] + private ?string $description = null; + + #[ORM\Column(length: 2048, nullable: true)] + #[Groups(['client_ticket:read', 'client_ticket:write'])] + private ?string $url = null; + + #[ORM\Column(length: 20)] + #[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])] + private ?string $status = 'new'; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + #[Groups(['client_ticket:read', 'client_ticket:write'])] + private ?string $statusComment = null; + + #[ORM\ManyToOne(targetEntity: Project::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[Groups(['client_ticket:read', 'client_ticket:write'])] + private ?Project $project = null; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + #[Groups(['client_ticket:read'])] + private ?User $submittedBy = null; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + #[Groups(['client_ticket:read'])] + private ?DateTimeImmutable $createdAt = null; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + #[Groups(['client_ticket:read'])] + private ?DateTimeImmutable $updatedAt = null; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: TaskDocument::class, mappedBy: 'clientTicket', cascade: ['remove'])] + #[Groups(['client_ticket:read'])] + private Collection $documents; + + public function __construct() + { + $this->documents = new ArrayCollection(); + } + + 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 getType(): ?string + { + return $this->type; + } + + public function setType(string $type): static + { + $this->type = $type; + + return $this; + } + + 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 getStatus(): ?string + { + return $this->status; + } + + public function setStatus(string $status): static + { + $this->status = $status; + + return $this; + } + + public function getStatusComment(): ?string + { + return $this->statusComment; + } + + public function setStatusComment(?string $statusComment): static + { + $this->statusComment = $statusComment; + + return $this; + } + + public function getProject(): ?Project + { + return $this->project; + } + + public function setProject(?Project $project): static + { + $this->project = $project; + + return $this; + } + + public function getSubmittedBy(): ?User + { + return $this->submittedBy; + } + + public function setSubmittedBy(?User $submittedBy): static + { + $this->submittedBy = $submittedBy; + + return $this; + } + + public function getCreatedAt(): ?DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getUpdatedAt(): ?DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(DateTimeImmutable $updatedAt): static + { + $this->updatedAt = $updatedAt; + + return $this; + } + + /** @return Collection */ + public function getDocuments(): Collection + { + return $this->documents; + } +} +``` + +- [ ] **Commit:** +```bash +git add src/Entity/ClientTicket.php +git commit -m "feat(entity) : create ClientTicket entity with API Platform operations" +``` + +### Task 8: Add clientTicket field to Task entity + +- [ ] **Modify `src/Entity/Task.php`** — Add the `clientTicket` property after the `$documents` property (after line 105): +```php + #[ORM\ManyToOne(targetEntity: ClientTicket::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + #[Groups(['task:read', 'task:write'])] + private ?ClientTicket $clientTicket = null; +``` + +- [ ] **Add getter and setter** at the end of the class (before the closing `}`): +```php + public function getClientTicket(): ?ClientTicket + { + return $this->clientTicket; + } + + public function setClientTicket(?ClientTicket $clientTicket): static + { + $this->clientTicket = $clientTicket; + + return $this; + } +``` + +- [ ] **Commit:** +```bash +git add src/Entity/Task.php +git commit -m "feat(entity) : add clientTicket relation to Task entity" +``` + +### Task 9: Generalize TaskDocument entity for client tickets + +- [ ] **Modify `src/Entity/TaskDocument.php`** — Make `task` nullable. Replace line 47: +```php + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] +``` +With: +```php + #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')] +``` + +- [ ] **Add `clientTicket` property** after the `$task` property (after line 49): +```php + #[ORM\ManyToOne(targetEntity: ClientTicket::class, inversedBy: 'documents')] + #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')] + #[Groups(['task_document:read', 'task_document:write'])] + private ?ClientTicket $clientTicket = null; +``` + +- [ ] **Add the `clientTicket` ApiFilter** — Replace the existing ApiFilter line (line 35): +```php +#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact'])] +``` +With: +```php +#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact', 'clientTicket' => 'exact'])] +``` + +- [ ] **Update the Post operation security** to also allow ROLE_CLIENT. Replace (line 24-28): +```php + new Post( + security: "is_granted('ROLE_ADMIN')", + processor: TaskDocumentProcessor::class, + deserialize: false, + ), +``` +With: +```php + new Post( + security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')", + processor: TaskDocumentProcessor::class, + deserialize: false, + ), +``` + +- [ ] **Add `client_ticket:read` to TaskDocument's serialization Groups** so that the `documents` relation on ClientTicket embeds document data instead of plain IRIs. Find the existing Groups on the following properties and add `'client_ticket:read'`: + - `$id` — add `'client_ticket:read'` + - `$originalName` — add `'client_ticket:read'` + - `$fileName` — add `'client_ticket:read'` + - `$mimeType` — add `'client_ticket:read'` + - `$size` — add `'client_ticket:read'` + - `$createdAt` — add `'client_ticket:read'` + - `$uploadedBy` — add `'client_ticket:read'` + +For example, replace: +```php + #[Groups(['task_document:read'])] + private ?int $id = null; +``` +With: +```php + #[Groups(['task_document:read', 'client_ticket:read'])] + private ?int $id = null; +``` +Apply the same pattern to `originalName`, `fileName`, `mimeType`, `size`, `createdAt`, and `uploadedBy`. + +- [ ] **Add getter and setter for clientTicket** after the `setTask()` method (after line 91): +```php + public function getClientTicket(): ?ClientTicket + { + return $this->clientTicket; + } + + public function setClientTicket(?ClientTicket $clientTicket): static + { + $this->clientTicket = $clientTicket; + + return $this; + } +``` + +- [ ] **Commit:** +```bash +git add src/Entity/TaskDocument.php +git commit -m "feat(entity) : generalize TaskDocument to support clientTicket attachments" +``` + +--- + +## Chunk 3: Migration & Repository + +### Task 10: Generate Doctrine migration + +- [ ] **Run the migration diff command:** +```bash +docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff +``` +Expected output: `Generated new migration class to "migrations/VersionXXXXXXXXXXXXXX.php"` + +- [ ] **Review the generated migration file** — Open the file at `migrations/VersionXXXXXXXXXXXXXX.php` (the filename will be timestamped). Verify it contains: + - `CREATE TABLE client_ticket` with columns: `id`, `number`, `type`, `title`, `description`, `url`, `status`, `status_comment`, `project_id`, `submitted_by_id`, `created_at`, `updated_at` + - `CREATE TABLE user_allowed_projects` with columns: `user_id`, `project_id` + - `ALTER TABLE "user" ADD client_id` (nullable FK) + - `ALTER TABLE task ADD client_ticket_id` (nullable FK) + - `ALTER TABLE task_document` making `task_id` nullable and adding `client_ticket_id` + - Unique constraint on `(project_id, number)` for `client_ticket` + +- [ ] **Add CHECK constraint to the migration** — In the `up()` method, add this line after the `task_document` alterations: +```php + $this->addSql('ALTER TABLE task_document ADD CONSTRAINT chk_document_owner CHECK (task_id IS NOT NULL OR client_ticket_id IS NOT NULL)'); +``` +And in the `down()` method, add before the other `task_document` reversals: +```php + $this->addSql('ALTER TABLE task_document DROP CONSTRAINT IF EXISTS chk_document_owner'); +``` + +- [ ] **Commit:** +```bash +git add migrations/ +git commit -m "feat(migration) : add ClientTicket table, User client fields, Task clientTicket, generalize TaskDocument" +``` + +### Task 11: Run migration + +- [ ] **Execute the migration:** +```bash +docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction +``` +Expected output: migration applied successfully, no errors. + +### Task 12: Create ClientTicketRepository + +- [ ] **Create `src/Repository/ClientTicketRepository.php`** with the following complete content: +```php + + */ +class ClientTicketRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ClientTicket::class); + } + + public function findNextNumberForProject(Project $project): int + { + $result = $this->createQueryBuilder('ct') + ->select('MAX(ct.number)') + ->where('ct.project = :project') + ->setParameter('project', $project) + ->getQuery() + ->getSingleScalarResult() + ; + + return ((int) ($result ?? 0)) + 1; + } +} +``` + +- [ ] **Commit:** +```bash +git add src/Repository/ClientTicketRepository.php +git commit -m "feat(repository) : create ClientTicketRepository with findNextNumberForProject" +``` + +--- + +## Chunk 4: State Providers & Processors + +### Task 13: Create ClientTicketNumberProcessor (POST) + +- [ ] **Create `src/State/ClientTicketNumberProcessor.php`** with the following complete content: +```php + + */ +final readonly class ClientTicketNumberProcessor implements ProcessorInterface +{ + public function __construct( + private EntityManagerInterface $entityManager, + private Security $security, + private ClientTicketRepository $clientTicketRepository, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket + { + assert($data instanceof ClientTicket); + + /** @var User $user */ + $user = $this->security->getUser(); + + if (null === $user->getClient()) { + throw new AccessDeniedHttpException('Only client users can create tickets.'); + } + + $project = $data->getProject(); + + if (null === $project) { + throw new BadRequestHttpException('Project is required.'); + } + + if (!$user->getAllowedProjects()->contains($project)) { + throw new AccessDeniedHttpException('You do not have access to this project.'); + } + + $nextNumber = $this->clientTicketRepository->findNextNumberForProject($project); + $data->setNumber($nextNumber); + $data->setSubmittedBy($user); + $data->setStatus('new'); + $data->setCreatedAt(new \DateTimeImmutable()); + $data->setUpdatedAt(new \DateTimeImmutable()); + + $this->entityManager->persist($data); + $this->entityManager->flush(); + + return $data; + } +} +``` + +- [ ] **Commit:** +```bash +git add src/State/ClientTicketNumberProcessor.php +git commit -m "feat(state) : create ClientTicketNumberProcessor for auto-numbering and validation" +``` + +### Task 14: Create ClientTicketStatusProcessor (PATCH) + +- [ ] **Create `src/State/ClientTicketStatusProcessor.php`** with the following complete content: +```php + + */ +final readonly class ClientTicketStatusProcessor implements ProcessorInterface +{ + /** @var array> */ + private const FORBIDDEN_TRANSITIONS = [ + 'done' => ['new'], + 'rejected' => ['new'], + ]; + + public function __construct( + private EntityManagerInterface $entityManager, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket + { + assert($data instanceof ClientTicket); + + $originalData = $context['previous_data'] ?? null; + + if ($originalData instanceof ClientTicket) { + $oldStatus = $originalData->getStatus(); + $newStatus = $data->getStatus(); + + if ($oldStatus !== $newStatus) { + $forbidden = self::FORBIDDEN_TRANSITIONS[$oldStatus] ?? []; + + if (in_array($newStatus, $forbidden, true)) { + throw new BadRequestHttpException(sprintf('Transition from "%s" to "%s" is not allowed.', $oldStatus, $newStatus)); + } + + if ('rejected' === $newStatus && (null === $data->getStatusComment() || '' === trim($data->getStatusComment()))) { + throw new BadRequestHttpException('A comment is required when rejecting a ticket.'); + } + } + } + + $data->setUpdatedAt(new \DateTimeImmutable()); + + $this->entityManager->persist($data); + $this->entityManager->flush(); + + return $data; + } +} +``` + +- [ ] **Commit:** +```bash +git add src/State/ClientTicketStatusProcessor.php +git commit -m "feat(state) : create ClientTicketStatusProcessor with transition validation" +``` + +### Task 15: Create ClientTicketProvider (GetCollection + Get) + +- [ ] **Create `src/State/ClientTicketProvider.php`** with the following complete content: +```php + + */ +final readonly class ClientTicketProvider implements ProviderInterface +{ + public function __construct( + private Security $security, + private EntityManagerInterface $entityManager, + ) {} + + /** + * @return ClientTicket|list|null + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): ClientTicket|array|null + { + $user = $this->security->getUser(); + assert($user instanceof User); + $repo = $this->entityManager->getRepository(ClientTicket::class); + + // Single item + if (isset($uriVariables['id'])) { + $ticket = $repo->find($uriVariables['id']); + if (null === $ticket) { + return null; + } + if (!$this->security->isGranted('ROLE_ADMIN') && $ticket->getSubmittedBy() !== $user) { + return null; + } + return $ticket; + } + + // Collection with manual filtering + $qb = $repo->createQueryBuilder('ct') + ->orderBy('ct.createdAt', 'DESC'); + + // ROLE_CLIENT: only own tickets + if (!$this->security->isGranted('ROLE_ADMIN')) { + $qb->andWhere('ct.submittedBy = :user')->setParameter('user', $user); + } + + // Apply filters from query parameters + $filters = $context['filters'] ?? []; + if (isset($filters['project'])) { + $qb->andWhere('ct.project = :project')->setParameter('project', (int) basename($filters['project'])); + } + if (isset($filters['status'])) { + $qb->andWhere('ct.status = :status')->setParameter('status', $filters['status']); + } + if (isset($filters['submittedBy']) && $this->security->isGranted('ROLE_ADMIN')) { + $qb->andWhere('ct.submittedBy = :submittedBy')->setParameter('submittedBy', (int) basename($filters['submittedBy'])); + } + + return $qb->getQuery()->getResult(); + } +} +``` + +- [ ] **Commit:** +```bash +git add src/State/ClientTicketProvider.php +git commit -m "feat(state) : create ClientTicketProvider with role-based filtering" +``` + +### Task 16: Generalize TaskDocumentProcessor for client tickets + +- [ ] **Modify `src/State/TaskDocumentProcessor.php`** — Add the ClientTicket import and AccessDeniedHttpException import after the existing imports (after line 10): +```php +use App\Entity\ClientTicket; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +``` + +- [ ] **Add `Security` dependency injection** — Add `private Security $security` to the constructor parameters and add the import: +```php +use Symfony\Bundle\SecurityBundle\Security; +``` + +- [ ] **Replace the task IRI validation and lookup logic** (lines 53-65). Replace: +```php + $taskIri = $request->request->get('task'); + + if (null === $taskIri || '' === $taskIri) { + throw new BadRequestHttpException('Task IRI is required.'); + } + + // Extract task ID from IRI (e.g., "/api/tasks/42" -> 42) + $taskId = (int) basename((string) $taskIri); + $task = $this->entityManager->getRepository(Task::class)->find($taskId); + + if (null === $task) { + throw new BadRequestHttpException('Task not found.'); + } +``` +With: +```php + $taskIri = $request->request->get('task'); + $clientTicketIri = $request->request->get('clientTicket'); + + if ((null === $taskIri || '' === $taskIri) && (null === $clientTicketIri || '' === $clientTicketIri)) { + throw new BadRequestHttpException('Either task or clientTicket IRI is required.'); + } + + $task = null; + $clientTicket = null; + + if (null !== $taskIri && '' !== $taskIri) { + $taskId = (int) basename((string) $taskIri); + $task = $this->entityManager->getRepository(Task::class)->find($taskId); + + if (null === $task) { + throw new BadRequestHttpException('Task not found.'); + } + } + + if (null !== $clientTicketIri && '' !== $clientTicketIri) { + $clientTicketId = (int) basename((string) $clientTicketIri); + $clientTicket = $this->entityManager->getRepository(ClientTicket::class)->find($clientTicketId); + + if (null === $clientTicket) { + throw new BadRequestHttpException('Client ticket not found.'); + } + + // ROLE_CLIENT can only upload to their own tickets + if (null !== $clientTicket && !$this->security->isGranted('ROLE_ADMIN')) { + $currentUser = $this->security->getUser(); + if ($clientTicket->getSubmittedBy() !== $currentUser) { + throw new AccessDeniedHttpException('You can only upload documents to your own tickets.'); + } + } + } +``` + +- [ ] **Update the document creation** — Replace line 82: +```php + $document->setTask($task); +``` +With: +```php + $document->setTask($task); + $document->setClientTicket($clientTicket); +``` + +- [ ] **Commit:** +```bash +git add src/State/TaskDocumentProcessor.php +git commit -m "feat(state) : generalize TaskDocumentProcessor to accept task or clientTicket" +``` + +--- + +## Chunk 5: Admin User Management for Client Users + +### Task 17: Update UserData and UserWrite DTOs + +- [ ] **Modify `frontend/services/dto/user-data.ts`** — Replace the entire file content with: +```typescript +import type { Client } from './client' +import type { Project } from './project' + +export type UserData = { + id: number + '@id'?: string + username: string + roles: string[] + client?: Client | null + allowedProjects?: Project[] +} + +export type UserWrite = { + username: string + password?: string + roles: string[] + client?: string | null + allowedProjects?: string[] +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/user-data.ts +git commit -m "feat(frontend) : add client and allowedProjects to User DTOs" +``` + +### Task 18: Update UserDrawer to support client user creation + +- [ ] **Modify `frontend/components/user/UserDrawer.vue`** — Replace the entire file content with: +```vue + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/components/user/UserDrawer.vue +git commit -m "feat(frontend) : update UserDrawer to support client user creation with client and projects" +``` + +--- + +## Chunk 6: Frontend Services & DTOs + +### Task 19: Create ClientTicket DTO + +- [ ] **Create `frontend/services/dto/client-ticket.ts`** with the following content: +```typescript +import type { TaskDocument } from './task-document' +import type { UserData } from './user-data' + +export type ClientTicketType = 'bug' | 'improvement' | 'other' +export type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected' + +export type ClientTicket = { + '@id'?: string + id: number + number: number + type: ClientTicketType + title: string + description: string + url: string | null + status: ClientTicketStatus + statusComment: string | null + project: string + submittedBy: UserData | null + createdAt: string + updatedAt: string + documents: TaskDocument[] +} + +export type ClientTicketWrite = { + type: ClientTicketType + title: string + description: string + url?: string | null + project: string +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/client-ticket.ts +git commit -m "feat(frontend) : create ClientTicket TypeScript DTOs" +``` + +### Task 20: Create client-tickets service + +- [ ] **Create `frontend/services/client-tickets.ts`** with the following content: +```typescript +import type { ClientTicket, ClientTicketWrite } from './dto/client-ticket' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export function useClientTicketService() { + const api = useApi() + + async function getAll(params?: Record): Promise { + const data = await api.get>('/client_tickets', params) + return extractHydraMembers(data) + } + + async function getById(id: number): Promise { + return await api.get(`/client_tickets/${id}`) + } + + async function create(data: ClientTicketWrite): Promise { + return await api.post('/client_tickets', data as Record, { + toastSuccessKey: 'clientTicket.created', + }) + } + + async function updateStatus(id: number, status: string, statusComment?: string): Promise { + return await api.patch(`/client_tickets/${id}`, { + status, + ...(statusComment ? { statusComment } : {}), + }, { + toastSuccessKey: 'clientTicket.statusUpdated', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/client_tickets/${id}`, {}, { + toastSuccessKey: 'clientTicket.deleted', + }) + } + + return { getAll, getById, create, updateStatus, remove } +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/client-tickets.ts +git commit -m "feat(frontend) : create client-tickets API service" +``` + +### Task 21: Update Task DTO with clientTicket field + +- [ ] **Modify `frontend/services/dto/task.ts`** — Add the import at the top of the file (after line 8): +```typescript +import type { ClientTicket } from './client-ticket' +``` + +- [ ] **Add the `clientTicket` field** to the `Task` type. After the `documents: TaskDocument[]` line (line 23), add: +```typescript + clientTicket?: { id: number; number: number; type: string; status: string; title: string } | null +``` + +- [ ] **Add the `clientTicket` field** to the `TaskWrite` type. After the `tags: string[]` line (line 36), add: +```typescript + clientTicket?: string | null +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/task.ts +git commit -m "feat(frontend) : add clientTicket field to Task DTO" +``` + +### Task 22: Update TaskDocument DTO with clientTicket field + +- [ ] **Modify `frontend/services/dto/task-document.ts`** — Replace the entire content with: +```typescript +import type { UserData } from './user-data' + +export type TaskDocument = { + '@id'?: string + id: number + task: string | null + clientTicket?: string | null + originalName: string + fileName: string + mimeType: string + size: number + createdAt: string + uploadedBy: UserData | null +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/task-document.ts +git commit -m "feat(frontend) : add clientTicket field to TaskDocument DTO" +``` + +### Task 23: Add i18n translations for client portal + +- [ ] **Modify `frontend/i18n/locales/fr.json`** — Add the following keys at the end of the JSON object, before the closing `}`. After the `"bookstack"` block (after line 237), add: +```json + , + "portal": { + "title": "Portail client", + "projects": "Mes projets", + "openTickets": "tickets ouverts", + "noProjects": "Aucun projet disponible.", + "newTicket": "Nouveau ticket", + "ticketDetail": "Détail du ticket" + }, + "clientTicket": { + "title": "Tickets client", + "new": "Nouveau ticket", + "created": "Ticket créé avec succès.", + "deleted": "Ticket supprimé avec succès.", + "statusUpdated": "Statut du ticket mis à jour.", + "type": { + "bug": "Bug", + "improvement": "Amélioration", + "other": "Autre" + }, + "status": { + "new": "Nouveau", + "in_progress": "En cours", + "done": "Terminé", + "rejected": "Rejeté" + }, + "fields": { + "title": "Titre", + "description": "Description", + "url": "URL (page concernée)", + "urlPlaceholder": "https://example.com/page-concernee", + "type": "Type", + "project": "Projet" + }, + "confirmDelete": "Supprimer ce ticket ?", + "rejectComment": "Commentaire de rejet", + "rejectCommentRequired": "Un commentaire est requis pour rejeter un ticket.", + "linkedTooltip": "Lié au ticket client CT-{number}" + } +``` + +- [ ] **Commit:** +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(i18n) : add French translations for client portal and client tickets" +``` diff --git a/docs/superpowers/plans/2026-03-15-client-portal-phase2.md b/docs/superpowers/plans/2026-03-15-client-portal-phase2.md new file mode 100644 index 0000000..badd515 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-client-portal-phase2.md @@ -0,0 +1,1960 @@ +# Client Portal Phase 2 — Portal & UI + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the client-facing portal pages (project list, ticket list, ticket creation with document upload), add client ticket indicators on internal kanban/my-tasks views, and create the admin "Tickets client" tab for managing all tickets. + +**Architecture:** Portal pages live under `/portal/` and use the existing default layout with a simplified sidebar for ROLE_CLIENT users. Auth middleware is extended to redirect ROLE_CLIENT to `/portal` and block internal pages. Client ticket data on internal task views flows through the `task:read` serialization group (no extra API call). Admin tab follows the existing tab pattern in `admin.vue`. + +**Tech Stack:** PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16, Nuxt 4, Vue 3, TypeScript, Tailwind CSS + +**Spec:** `docs/superpowers/specs/2026-03-15-client-portal-design.md` + +**Depends on:** Phase 1 (`docs/superpowers/plans/2026-03-15-client-portal-phase1.md`) + +--- + +## Chunk 1: Auth Middleware & Portal Layout + +### Task 1: Update auth middleware for ROLE_CLIENT routing + +- [ ] **Modify `frontend/middleware/auth.global.ts`** — Add ROLE_CLIENT redirect logic. After the existing login redirect (line 14), add portal routing. Replace the full file with: + +```typescript +export default defineNuxtRouteMiddleware(async (to) => { + const auth = useAuthStore() + const isLogin = to.path === '/login' + + if (!auth.checked) { + await auth.ensureSession() + } + + if (!isLogin && !auth.isAuthenticated) { + return navigateTo('/login') + } + + if (isLogin && auth.isAuthenticated) { + const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false + return navigateTo(isClient ? '/portal' : '/') + } + + // ROLE_CLIENT: redirect to /portal, block internal pages + if (auth.isAuthenticated && auth.user?.roles?.includes('ROLE_CLIENT')) { + const isPortalRoute = to.path.startsWith('/portal') + const isLoginRoute = to.path === '/login' + if (!isPortalRoute && !isLoginRoute) { + return navigateTo('/portal') + } + } +}) +``` + +- [ ] **Commit:** +```bash +git add frontend/middleware/auth.global.ts +git commit -m "feat(auth) : redirect ROLE_CLIENT to /portal and block internal pages" +``` + +### Task 2: Create portal layout + +- [ ] **Create `frontend/layouts/portal.vue`** — Simplified layout for client users with minimal sidebar (logo, portal link, logout): + +```vue + + + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/layouts/portal.vue +git commit -m "feat(portal) : add portal layout with simplified sidebar for client users" +``` + +### Task 3: Add i18n keys for portal and client tickets + +- [ ] **Modify `frontend/i18n/locales/fr.json`** — Add portal and clientTicket sections. After the `"bookstack"` block (before the closing `}`), add: + +```json + "portal": { + "title": "Portail client", + "projects": "Mes projets", + "openTickets": "tickets ouverts", + "noProjects": "Aucun projet disponible.", + "newTicket": "Nouveau ticket", + "ticketDetail": "Détail du ticket", + "backToProject": "Retour au projet", + "submitTicket": "Soumettre le ticket", + "ticketCreated": "Ticket soumis avec succès." + }, + "clientTicket": { + "type": { + "bug": "Bug", + "improvement": "Amélioration", + "other": "Autre" + }, + "status": { + "new": "Nouveau", + "in_progress": "En cours", + "done": "Terminé", + "rejected": "Rejeté" + }, + "title": "Titre", + "description": "Description", + "url": "URL (page concernée)", + "statusComment": "Commentaire de statut", + "created": "Ticket créé", + "statusChanged": "Statut mis à jour", + "confirmDelete": "Supprimer ce ticket ?", + "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.", + "linkedTooltip": "Lié au ticket client {number}", + "rejectionRequired": "Un commentaire est requis pour rejeter un ticket", + "noTickets": "Aucun ticket.", + "allStatuses": "Tous les statuts", + "allProjects": "Tous les projets", + "submittedBy": "Soumis par", + "createdAt": "Créé le", + "deleted": "Ticket supprimé avec succès.", + "statusUpdated": "Statut mis à jour avec succès.", + "adminTab": "Tickets client", + "selectType": "Type de ticket", + "changeStatus": "Changer le statut" + } +``` + +- [ ] **Commit:** +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(i18n) : add portal and client ticket translation keys" +``` + +--- + +## Chunk 2: DTOs & Services + +### Task 4: Create ClientTicket DTO + +- [ ] **Create `frontend/services/dto/client-ticket.ts`** — TypeScript types for client tickets: + +```typescript +import type { TaskDocument } from './task-document' + +export type ClientTicketType = 'bug' | 'improvement' | 'other' +export type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected' + +export type ClientTicket = { + '@id'?: string + id: number + number: number + type: ClientTicketType + title: string + description: string + url: string | null + status: ClientTicketStatus + statusComment: string | null + project: string + submittedBy: string | null + createdAt: string + updatedAt: string + documents?: TaskDocument[] +} + +export type ClientTicketWrite = { + type: ClientTicketType + title: string + description: string + url?: string | null + project: string +} + +export type ClientTicketStatusUpdate = { + status: ClientTicketStatus + statusComment?: string | null +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/client-ticket.ts +git commit -m "feat(dto) : add ClientTicket TypeScript types" +``` + +### Task 5: Create client-tickets service + +- [ ] **Create `frontend/services/client-tickets.ts`** — API service for client tickets following the existing service pattern (`useTaskService`): + +```typescript +import type { ClientTicket, ClientTicketWrite, ClientTicketStatusUpdate } from './dto/client-ticket' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export function useClientTicketService() { + const api = useApi() + + async function getAll(params?: { project?: number; status?: string; submittedBy?: number }): Promise { + const query: Record = {} + if (params?.project) query.project = `/api/projects/${params.project}` + if (params?.status) query.status = params.status + if (params?.submittedBy) query.submittedBy = `/api/users/${params.submittedBy}` + const data = await api.get>('/client_tickets', query) + return extractHydraMembers(data) + } + + async function getById(id: number): Promise { + return api.get(`/client_tickets/${id}`) + } + + async function create(payload: ClientTicketWrite): Promise { + return api.post('/client_tickets', payload as Record, { + toastSuccessKey: 'portal.ticketCreated', + }) + } + + async function updateStatus(id: number, payload: ClientTicketStatusUpdate): Promise { + return api.patch(`/client_tickets/${id}`, payload as Record, { + toastSuccessKey: 'clientTicket.statusUpdated', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/client_tickets/${id}`, {}, { + toastSuccessKey: 'clientTicket.deleted', + }) + } + + return { getAll, getById, create, updateStatus, remove } +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/client-tickets.ts +git commit -m "feat(service) : add client-tickets API service" +``` + +### Task 6: Extend Task DTO with clientTicket field + +- [ ] **Modify `frontend/services/dto/task.ts`** — Add `clientTicket` field to the `Task` type. After the `documents: TaskDocument[]` line (line 23), add: + +```typescript + clientTicket: { + id: number + number: number + type: string + status: string + title: string + } | null +``` + +The full `Task` type should now include `clientTicket` after `documents`: + +```typescript +import type { TaskStatus } from './task-status' +import type { TaskEffort } from './task-effort' +import type { TaskPriority } from './task-priority' +import type { TaskTag } from './task-tag' +import type { TaskGroup } from './task-group' +import type { UserData } from './user-data' +import type { Project } from './project' +import type { TaskDocument } from './task-document' + +export type Task = { + id: number + '@id'?: string + number: number + title: string + description: string | null + status: TaskStatus | null + effort: TaskEffort | null + priority: TaskPriority | null + assignee: UserData | null + group: TaskGroup | null + project: Project | null + tags: TaskTag[] + documents: TaskDocument[] + archived: boolean + clientTicket: { + id: number + number: number + type: string + status: string + title: string + } | null +} + +export type TaskWrite = { + title: string + description: string | null + status: string | null + effort: string | null + priority: string | null + assignee: string | null + group: string | null + project: string + tags: string[] + archived?: boolean +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/task.ts +git commit -m "feat(dto) : add clientTicket field to Task type" +``` + +### Task 7: Update UserData DTO for allowedProjects + +- [ ] **Modify `frontend/services/dto/user-data.ts`** — Add `client` and `allowedProjects` fields for client users. This must happen before portal pages are built because `auth.user.allowedProjects` needs proper typing. Replace the full file with: + +```typescript +import type { Project } from './project' + +export type UserData = { + id: number + '@id'?: string + username: string + roles: string[] + client?: { id: number; name: string } | null + allowedProjects?: Project[] +} + +export type UserWrite = { + username: string + password?: string + roles: string[] + client?: string | null + allowedProjects?: string[] +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/user-data.ts +git commit -m "feat(dto) : add client and allowedProjects fields to UserData type" +``` + +--- + +## Chunk 3: Portal Pages + +### Task 8: Create portal project list page + +- [ ] **Create `frontend/pages/portal/index.vue`** — List of client's allowed projects with open ticket count. Uses the `portal` layout. **Note:** For admin users (ROLE_ADMIN), the page loads all projects via the projects service as a fallback, since admins have no `allowedProjects`: + +```vue + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/pages/portal/index.vue +git commit -m "feat(portal) : add portal project list page" +``` + +### Task 9: Create portal ticket list page + +- [ ] **Create `frontend/pages/portal/projects/[id]/index.vue`** — List of tickets for a project with status badges and ticket detail modal: + +```vue + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/pages/portal/projects/[id]/index.vue +git commit -m "feat(portal) : add ticket list page for a project" +``` + +### Task 10: Create ClientTicketDetailModal component + +- [ ] **Create `frontend/components/client-ticket/ClientTicketDetailModal.vue`** — Read-only modal showing ticket details (title, description, url, status, statusComment, documents). Follows the `TaskModal` pattern for styling: + +```vue + + + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/components/client-ticket/ClientTicketDetailModal.vue +git commit -m "feat(portal) : add client ticket detail modal component" +``` + +### Task 11: Create new ticket form page + +- [ ] **Create `frontend/pages/portal/projects/[id]/new-ticket.vue`** — Ticket creation form with type select, title, description, url (if bug), and document upload: + +```vue + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/pages/portal/projects/[id]/new-ticket.vue +git commit -m "feat(portal) : add new ticket creation form page" +``` + +--- + +## Chunk 4: Document Upload on Tickets + +### Task 12: Generalize TaskDocumentUpload with optional clientTicketId prop + +- [ ] **Modify `frontend/components/task/TaskDocumentUpload.vue`** — Add an optional `clientTicketId` prop as an alternative to `taskId`. Replace the ` +``` + +Also update the template references. Replace: +```vue + +``` + +With: +```vue + +``` + +And replace: +```vue + +``` + +With: +```vue + +``` + +- [ ] **Commit:** +```bash +git add frontend/components/client-ticket/ClientTicketDetailModal.vue +git commit -m "feat(portal) : add document upload to ticket detail modal" +``` + +--- + +## Chunk 5: Client Ticket Icon on Internal Views + +### Task 15: Add client ticket icon to TaskCard + +- [ ] **Modify `frontend/components/task/TaskCard.vue`** — Add a small `heroicons:user-circle` icon next to the task code if `task.clientTicket` is set. In the template, after the `` showing `task.project.code` (line 11), add the icon. Replace: + +```vue + {{ task.project.code }}{{ task.number }} +``` + +With: +```vue +
+ {{ task.project.code }}{{ task.number }} + +
+``` + +- [ ] **Commit:** +```bash +git add frontend/components/task/TaskCard.vue +git commit -m "feat(kanban) : show client ticket icon on task cards linked to a ticket" +``` + +### Task 16: Add client ticket icon to my-tasks list view + +- [ ] **Modify `frontend/pages/my-tasks.vue`** — Add the same `heroicons:user-circle` icon in the list view. In the list view task row, after the task code span (around line 418), add the icon. Replace: + +```vue + + {{ task.project.code }}-{{ task.number }} + +``` + +With: +```vue +
+ + + {{ task.project.code }}-{{ task.number }} + +
+``` + +- [ ] **Commit:** +```bash +git add frontend/pages/my-tasks.vue +git commit -m "feat(my-tasks) : show client ticket icon on tasks linked to a ticket" +``` + +### Task 17: Show client ticket info in TaskModal + +- [ ] **Modify `frontend/components/task/TaskModal.vue`** — Show client ticket link info when editing a task that has `clientTicket` set. In the template, after the header `

` tag (line 27), add a client ticket badge. After the closing `` of the header flex container (line 29), add: + +```vue + +
+ + + {{ $t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') }) }} + + + {{ $t(`clientTicket.status.${task.clientTicket.status}`) }} + +
+``` + +In the ` + + +``` + +- [ ] **Commit:** +```bash +git add frontend/components/admin/AdminClientTicketTab.vue +git commit -m "feat(admin) : add client tickets tab with list, filters, status change, and delete" +``` + +### Task 19: Register the new tab in admin.vue + +- [ ] **Modify `frontend/pages/admin.vue`** — Add the "Tickets client" tab. In the `tabs` array (line 39), add a new entry after the `bookstack` tab: + +Replace: +```typescript +const tabs = [ + { key: 'clients', label: 'Clients' }, + { key: 'statuses', label: 'Statuts' }, + { key: 'efforts', label: 'Efforts' }, + { key: 'priorities', label: 'Priorités' }, + { key: 'tags', label: 'Tags' }, + { key: 'users', label: 'Utilisateurs' }, + { key: 'gitea', label: 'Gitea' }, + { key: 'bookstack', label: 'BookStack' }, +] as const +``` + +With: +```typescript +const tabs = [ + { key: 'clients', label: 'Clients' }, + { key: 'statuses', label: 'Statuts' }, + { key: 'efforts', label: 'Efforts' }, + { key: 'priorities', label: 'Priorités' }, + { key: 'tags', label: 'Tags' }, + { key: 'users', label: 'Utilisateurs' }, + { key: 'client-tickets', label: 'Tickets client' }, + { key: 'gitea', label: 'Gitea' }, + { key: 'bookstack', label: 'BookStack' }, +] as const +``` + +In the template, after the `AdminBookStackTab` (line 31), add: +```vue + +``` + +- [ ] **Commit:** +```bash +git add frontend/pages/admin.vue +git commit -m "feat(admin) : register client tickets tab in admin page" +``` + +--- + +## Chunk 7: Final Touches + +### Task 20: Update login redirect to portal for client users + +- [ ] **Modify `frontend/pages/login.vue`** — After successful login, redirect ROLE_CLIENT users to `/portal` instead of `/`. The actual login page uses `router.push`, not `navigateTo`. + +Find this line (around line 66): +```typescript + await router.push('/') +``` + +Replace with: +```typescript + const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false + await router.push(isClient ? '/portal' : '/') +``` + +- [ ] **Commit:** +```bash +git add frontend/pages/login.vue +git commit -m "feat(auth) : redirect client users to /portal after login" +``` + +### Task 21: Extract duplicated helpers to composable + +- [ ] **Create `frontend/composables/useClientTicketHelpers.ts`** — Extract the `typeBadgeClass`, `statusBadgeClass`, and `formatDate` functions that are duplicated in `ClientTicketDetailModal.vue`, `portal/projects/[id]/index.vue`, and `AdminClientTicketTab.vue`: + +```typescript +export function useClientTicketHelpers() { + function typeBadgeClass(type: string): string { + switch (type) { + case 'bug': return 'bg-red-500' + case 'improvement': return 'bg-blue-500' + default: return 'bg-neutral-500' + } + } + + function statusBadgeClass(status: string): string { + switch (status) { + case 'new': return 'bg-blue-100 text-blue-700' + case 'in_progress': return 'bg-yellow-100 text-yellow-700' + case 'done': return 'bg-green-100 text-green-700' + case 'rejected': return 'bg-red-100 text-red-700' + default: return 'bg-neutral-100 text-neutral-700' + } + } + + function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) + } + + return { typeBadgeClass, statusBadgeClass, formatDate } +} +``` + +- [ ] **Update the 3 components** to import and use the composable instead of local functions: + - `frontend/components/client-ticket/ClientTicketDetailModal.vue` — Remove local `typeBadgeClass`, `statusBadgeClass`, `formatDate` functions and add `const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()` + - `frontend/pages/portal/projects/[id]/index.vue` — Same replacement + - `frontend/components/admin/AdminClientTicketTab.vue` — Remove local `typeBadgeClass`, `statusBadgeClass`, `formatDate` functions and add `const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()` + +- [ ] **Commit:** +```bash +git add frontend/composables/useClientTicketHelpers.ts frontend/components/client-ticket/ClientTicketDetailModal.vue frontend/pages/portal/projects/\[id\]/index.vue frontend/components/admin/AdminClientTicketTab.vue +git commit -m "refactor(portal) : extract duplicated ticket helpers to useClientTicketHelpers composable" +``` + +### Task 22: Final commit — verify all files + +- [ ] **Run a final check** — Verify all new files are properly created and existing files are updated: +```bash +git status +``` + +Verify the following files exist: +- `frontend/middleware/auth.global.ts` (modified) +- `frontend/layouts/portal.vue` (new) +- `frontend/i18n/locales/fr.json` (modified) +- `frontend/services/dto/client-ticket.ts` (new) +- `frontend/services/client-tickets.ts` (new) +- `frontend/services/dto/task.ts` (modified) +- `frontend/services/dto/user-data.ts` (modified) +- `frontend/services/task-documents.ts` (modified) +- `frontend/pages/portal/index.vue` (new) +- `frontend/pages/portal/projects/[id]/index.vue` (new) +- `frontend/pages/portal/projects/[id]/new-ticket.vue` (new) +- `frontend/components/client-ticket/ClientTicketDetailModal.vue` (new) +- `frontend/components/task/TaskDocumentUpload.vue` (modified) +- `frontend/components/task/TaskCard.vue` (modified) +- `frontend/components/task/TaskModal.vue` (modified) +- `frontend/pages/my-tasks.vue` (modified) +- `frontend/pages/admin.vue` (modified) +- `frontend/components/admin/AdminClientTicketTab.vue` (new) +- `frontend/pages/login.vue` (modified) +- `frontend/composables/useClientTicketHelpers.ts` (new) diff --git a/docs/superpowers/plans/2026-03-15-client-portal-phase3.md b/docs/superpowers/plans/2026-03-15-client-portal-phase3.md new file mode 100644 index 0000000..b61a712 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-client-portal-phase3.md @@ -0,0 +1,970 @@ +# Client Portal Phase 3 — Notifications + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an in-app notification system so admins are alerted when a client submits a ticket, and clients are alerted when a ticket status changes. Includes a bell icon with dropdown in the navbar, a polling composable, and the full backend (entity, provider, controller, service). + +**Architecture:** `Notification` entity with API Platform CRUD (GetCollection auto-filtered by current user, Patch to mark as read) plus two custom Symfony endpoints (unread-count, mark-all-read). A `NotificationService` is called from the existing `ClientTicketNumberProcessor` (POST) and `ClientTicketStatusProcessor` (PATCH). Frontend uses a `useNotifications()` composable with 2-minute polling, rendered in a `NotificationBell.vue` component placed in `AppTopNav.vue`. + +> **Note:** Notification creation is handled via `NotificationService` injected into existing processors (`ClientTicketNumberProcessor` and `ClientTicketStatusProcessor`), rather than a separate `ClientTicketNotificationProcessor`. This is simpler and avoids processor decorator complexity. + +**Tech Stack:** PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16, Nuxt 4, Vue 3, TypeScript + +**Spec:** `docs/superpowers/specs/2026-03-15-client-portal-design.md` + +**Depends on:** Phase 1 + Phase 2 + +--- + +## Chunk 1: Notification Entity & Migration + +### Task 1: Create the Notification entity + +- [ ] **Create `src/Entity/Notification.php`** with the following content: + +```php + ['notification:read']], + denormalizationContext: ['groups' => ['notification:write']], + order: ['createdAt' => 'DESC'], +)] +#[ORM\Entity(repositoryClass: NotificationRepository::class)] +#[ORM\Index(columns: ['user_id'], name: 'idx_notification_user')] +#[ORM\Index(columns: ['user_id', 'is_read'], name: 'idx_notification_user_read')] +class Notification +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['notification:read'])] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[Groups(['notification:read'])] + private ?User $user = null; + + #[ORM\Column(length: 50)] + #[Groups(['notification:read'])] + private ?string $type = null; + + #[ORM\Column(length: 255)] + #[Groups(['notification:read'])] + private ?string $title = null; + + #[ORM\Column(type: Types::TEXT)] + #[Groups(['notification:read'])] + private ?string $message = null; + + #[ORM\ManyToOne(targetEntity: ClientTicket::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + #[Groups(['notification:read'])] + private ?ClientTicket $relatedTicket = null; + + #[ORM\Column] + #[Groups(['notification:read', 'notification:write'])] + private bool $isRead = false; + + #[ORM\Column] + #[Groups(['notification:read'])] + private ?DateTimeImmutable $createdAt = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(string $type): static + { + $this->type = $type; + + return $this; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function setMessage(string $message): static + { + $this->message = $message; + + return $this; + } + + public function getRelatedTicket(): ?ClientTicket + { + return $this->relatedTicket; + } + + public function setRelatedTicket(?ClientTicket $relatedTicket): static + { + $this->relatedTicket = $relatedTicket; + + return $this; + } + + public function isRead(): bool + { + return $this->isRead; + } + + public function setIsRead(bool $isRead): static + { + $this->isRead = $isRead; + + return $this; + } + + public function getCreatedAt(): ?DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } +} +``` + +### Task 2: Create the NotificationRepository + +- [ ] **Create `src/Repository/NotificationRepository.php`**: + +```php + + */ +class NotificationRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Notification::class); + } + + public function countUnreadByUser(User $user): int + { + return (int) $this->createQueryBuilder('n') + ->select('COUNT(n.id)') + ->where('n.user = :user') + ->andWhere('n.isRead = false') + ->setParameter('user', $user) + ->getQuery() + ->getSingleScalarResult(); + } + + public function markAllReadByUser(User $user): int + { + return $this->createQueryBuilder('n') + ->update() + ->set('n.isRead', 'true') + ->where('n.user = :user') + ->andWhere('n.isRead = false') + ->setParameter('user', $user) + ->getQuery() + ->executeStatement(); + } +} +``` + +### Task 3: Generate and run the migration + +- [ ] **Run inside the PHP container** (`make shell`): + +```bash +php bin/console doctrine:migrations:diff +php bin/console doctrine:migrations:migrate --no-interaction +``` + +Verify that the `notification` table is created with columns `id`, `user_id`, `type`, `title`, `message`, `related_ticket_id`, `is_read`, `created_at`, and the two indexes `idx_notification_user` and `idx_notification_user_read`. + +- [ ] **Commit:** +```bash +git add src/Entity/Notification.php src/Repository/NotificationRepository.php migrations/ +git commit -m "feat(notification) : add Notification entity, repository, and migration" +``` + +--- + +## Chunk 2: NotificationProvider & Custom Endpoints + +### Task 4: Create the NotificationProvider + +- [ ] **Create `src/State/NotificationProvider.php`** — auto-filters by the current user: + +```php + + */ +final readonly class NotificationProvider implements ProviderInterface +{ + public function __construct( + private Security $security, + private NotificationRepository $notificationRepository, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|object + { + $user = $this->security->getUser(); + + return $this->notificationRepository->findBy( + ['user' => $user], + ['createdAt' => 'DESC'], + 30, + ); + } +} +``` + +- [ ] **Commit:** +```bash +git add src/State/NotificationProvider.php +git commit -m "feat(notification) : add NotificationProvider filtered by current user" +``` + +### Task 5: Create the UnreadCountController + +- [ ] **Create `src/Controller/NotificationUnreadCountController.php`**: + +```php +getUser(); + + $count = $this->notificationRepository->countUnreadByUser($user); + + return new JsonResponse(['count' => $count]); + } +} +``` + +### Task 6: Create the MarkAllReadController + +- [ ] **Create `src/Controller/MarkAllReadController.php`**: + +```php +getUser(); + + $this->notificationRepository->markAllReadByUser($user); + + return new Response(null, Response::HTTP_NO_CONTENT); + } +} +``` + +- [ ] **Commit:** +```bash +git add src/Controller/NotificationUnreadCountController.php src/Controller/MarkAllReadController.php +git commit -m "feat(notification) : add unread-count and mark-all-read custom controllers" +``` + +--- + +## Chunk 3: NotificationService & Processor Integration + +### Task 7: Create NotificationService + +- [ ] **Create `src/Service/NotificationService.php`** — responsible for creating notifications: + +```php +userRepository->findByRole('ROLE_ADMIN'); + $number = sprintf('CT-%03d', $ticket->getNumber()); + $projectName = $ticket->getProject()?->getName() ?? ''; + + foreach ($admins as $admin) { + $notification = new Notification(); + $notification->setUser($admin); + $notification->setType('ticket_created'); + $notification->setTitle('Nouveau ticket client ' . $number); + $notification->setMessage($ticket->getTitle() . ' — ' . $projectName); + $notification->setRelatedTicket($ticket); + $notification->setCreatedAt(new DateTimeImmutable()); + + $this->entityManager->persist($notification); + } + + $this->entityManager->flush(); + } + + /** + * Notify the ticket submitter that the status has changed. + */ + public function createForStatusChange(ClientTicket $ticket): void + { + $submittedBy = $ticket->getSubmittedBy(); + + if (null === $submittedBy) { + return; + } + + $number = sprintf('CT-%03d', $ticket->getNumber()); + $statusLabel = $ticket->getStatus(); + $message = 'Nouveau statut : ' . $statusLabel; + + if (null !== $ticket->getStatusComment() && '' !== $ticket->getStatusComment()) { + $message .= ' — ' . $ticket->getStatusComment(); + } + + $notification = new Notification(); + $notification->setUser($submittedBy); + $notification->setType('ticket_status_changed'); + $notification->setTitle('Ticket ' . $number . ' mis à jour'); + $notification->setMessage($message); + $notification->setRelatedTicket($ticket); + $notification->setCreatedAt(new DateTimeImmutable()); + + $this->entityManager->persist($notification); + $this->entityManager->flush(); + } +} +``` + +### Task 8: Add findByRole method to UserRepository + +- [ ] **Modify `src/Repository/UserRepository.php`** — Add the `findByRole` method at the end of the class, before the closing `}`: + +```php + /** + * @return User[] + */ + public function findByRole(string $role): array + { + return $this->createQueryBuilder('u') + ->where('u.roles LIKE :role') + ->setParameter('role', '%"' . $role . '"%') + ->getQuery() + ->getResult(); + } +``` + +- [ ] **Commit:** +```bash +git add src/Service/NotificationService.php src/Repository/UserRepository.php +git commit -m "feat(notification) : add NotificationService and UserRepository::findByRole" +``` + +### Task 9: Hook NotificationService into ClientTicketNumberProcessor (POST) + +- [ ] **Modify `src/State/ClientTicketNumberProcessor.php`** — Inject `NotificationService` in the constructor and call `createForTicketCreated()` after the ticket is persisted: + +Add to constructor parameters: +```php +private readonly NotificationService $notificationService, +``` + +Add import at the top: +```php +use App\Service\NotificationService; +``` + +After `$this->entityManager->flush();` in the POST handling block, add: +```php +$this->notificationService->createForTicketCreated($data); +``` + +### Task 10: Hook NotificationService into ClientTicketStatusProcessor (PATCH) + +- [ ] **Modify `src/State/ClientTicketStatusProcessor.php`** — Inject `NotificationService` in the constructor and call `createForStatusChange()` after the status update is persisted: + +Add to constructor parameters: +```php +private readonly NotificationService $notificationService, +``` + +Add import at the top: +```php +use App\Service\NotificationService; +``` + +After `$this->entityManager->flush();` in the PATCH handling block, add: +```php +$this->notificationService->createForStatusChange($data); +``` + +- [ ] **Commit:** +```bash +git add src/State/ClientTicketNumberProcessor.php src/State/ClientTicketStatusProcessor.php +git commit -m "feat(notification) : hook NotificationService into ticket processors" +``` + +--- + +## Chunk 4: Frontend — DTO & Service + +### Task 11: Create the Notification DTO + +- [ ] **Create `frontend/services/dto/notification.ts`**: + +```typescript +export type NotificationType = 'ticket_created' | 'ticket_status_changed' + +export type Notification = { + '@id'?: string + id: number + user: string + type: NotificationType + title: string + message: string + relatedTicket: string | null + isRead: boolean + createdAt: string +} +``` + +### Task 12: Create the notifications service + +- [ ] **Create `frontend/services/notifications.ts`**: + +```typescript +import type { Notification } from './dto/notification' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export function useNotificationService() { + const api = useApi() + + async function getAll(): Promise { + const data = await api.get>('/notifications') + return extractHydraMembers(data) + } + + async function markAsRead(id: number): Promise { + await api.patch(`/notifications/${id}`, { isRead: true }, { + toast: false, + }) + } + + async function markAllAsRead(): Promise { + await api.post('/notifications/mark-all-read', {}, { + toast: false, + }) + } + + async function getUnreadCount(): Promise { + const data = await api.get<{ count: number }>('/notifications/unread-count', {}, { + toast: false, + }) + return data.count + } + + return { getAll, markAsRead, markAllAsRead, getUnreadCount } +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/notification.ts frontend/services/notifications.ts +git commit -m "feat(frontend) : add notification DTO and service" +``` + +--- + +## Chunk 5: Frontend — Composable & Component + +### Task 13: Create the useNotifications composable + +- [ ] **Create `frontend/composables/useNotifications.ts`**: + +```typescript +import type { Notification } from '~/services/dto/notification' +import { useNotificationService } from '~/services/notifications' + +const POLL_INTERVAL = 2 * 60 * 1000 // 2 minutes + +export function useNotifications() { + const unreadCount = useState('notification-unread-count', () => 0) + const notifications = useState('notification-list', () => []) + const isLoading = useState('notification-loading', () => false) + + const service = useNotificationService() + let pollTimer: ReturnType | null = null + + async function fetchUnreadCount(): Promise { + try { + unreadCount.value = await service.getUnreadCount() + } catch { + // Silently ignore polling errors + } + } + + async function fetchNotifications(): Promise { + isLoading.value = true + try { + notifications.value = await service.getAll() + } finally { + isLoading.value = false + } + } + + async function markAsRead(id: number): Promise { + await service.markAsRead(id) + const notif = notifications.value.find(n => n.id === id) + if (notif && !notif.isRead) { + notif.isRead = true + unreadCount.value = Math.max(0, unreadCount.value - 1) + } + } + + async function markAllAsRead(): Promise { + await service.markAllAsRead() + notifications.value.forEach(n => n.isRead = true) + unreadCount.value = 0 + } + + function startPolling(): void { + fetchUnreadCount() + pollTimer = setInterval(fetchUnreadCount, POLL_INTERVAL) + } + + function stopPolling(): void { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + } + + return { + unreadCount, + notifications, + isLoading, + fetchNotifications, + fetchUnreadCount, + markAsRead, + markAllAsRead, + startPolling, + stopPolling, + } +} +``` + +- [ ] **Commit:** +```bash +git add frontend/composables/useNotifications.ts +git commit -m "feat(frontend) : add useNotifications composable with polling" +``` + +### Task 14: Create the NotificationBell component + +- [ ] **Create `frontend/components/notification/NotificationBell.vue`**: + +```vue + + + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/components/notification/NotificationBell.vue +git commit -m "feat(frontend) : add NotificationBell component with dropdown" +``` + +--- + +## Chunk 6: Layout Integration & i18n + +### Task 15: Integrate NotificationBell in AppTopNav + +- [ ] **Modify `frontend/components/ui/AppTopNav.vue`** — Add the notification bell to the left of the user avatar. Replace the existing `
` block (line 10): + +Replace: +```vue +
+
+``` + +With: +```vue +
+ +
+``` + +No imports needed — Nuxt auto-imports components from `frontend/components/`. + +- [ ] **Commit:** +```bash +git add frontend/components/ui/AppTopNav.vue +git commit -m "feat(frontend) : integrate NotificationBell in AppTopNav navbar" +``` + +### Task 16: Add i18n translations + +- [ ] **Modify `frontend/i18n/locales/fr.json`** — Add the following keys in the root object (insert alongside existing top-level keys): + +```json +"notification": { + "title": "Notifications", + "markAllRead": "Tout marquer comme lu", + "empty": "Aucune notification", + "ticketCreated": "Nouveau ticket client {number}", + "ticketStatusChanged": "Ticket {number} mis à jour", + "timeAgo": { + "now": "À l'instant", + "minutes": "Il y a {n} min", + "hours": "Il y a {n}h", + "days": "Il y a {n}j" + } +} +``` + +- [ ] **Commit:** +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(i18n) : add notification translations in French" +``` + +--- + +## Chunk 7: Verification & Cleanup + +### Task 17: Test backend endpoints manually + +- [ ] **Test the notification API endpoints** using the admin user (`admin`/`admin`): + +1. Log in at `POST /login_check` with `{"username":"admin","password":"admin"}` +2. `GET /api/notifications` — should return empty hydra collection (latest 30, no pagination) +3. `GET /api/notifications/unread-count` — should return `{"count": 0}` +4. Create a test client ticket as a ROLE_CLIENT user (from Phase 1/2) and verify a notification is created for the admin +5. `GET /api/notifications` — should now list the `ticket_created` notification +6. `GET /api/notifications/unread-count` — should return `{"count": 1}` +7. `PATCH /api/notifications/{id}` with `{"isRead": true}` — should mark notification as read +8. `POST /api/notifications/mark-all-read` — should return 204 + +### Task 18: Test frontend notification bell + +- [ ] **Start dev server** (`make dev-nuxt`) and verify: + +1. The bell icon appears in the top navigation bar, to the left of the user avatar +2. Badge shows unread count (or is hidden when 0) +3. Clicking the bell opens a dropdown with notification list +4. Clicking a notification marks it as read and navigates appropriately +5. "Tout marquer comme lu" button works +6. Polling updates the badge every 2 minutes + +- [ ] **Final commit (if any fixes needed):** +```bash +git add -A +git commit -m "fix(notification) : polish notification bell and fix edge cases" +``` From edc441f363d6c254f4552eedc472384703154bb0 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:20:46 +0100 Subject: [PATCH 06/89] fix(security) : exclude ROLE_USER for ROLE_CLIENT users Co-Authored-By: Claude Sonnet 4.6 --- src/Entity/User.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Entity/User.php b/src/Entity/User.php index 2798069..5080d65 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -95,8 +95,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface /** @return list */ public function getRoles(): array { - $roles = $this->roles; - $roles[] = 'ROLE_USER'; + $roles = $this->roles; + + if (!in_array('ROLE_CLIENT', $roles, true)) { + $roles[] = 'ROLE_USER'; + } return array_values(array_unique($roles)); } From 63febbea455e99f55142953c9ae2bf1dd1724ec1 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:21:19 +0100 Subject: [PATCH 07/89] fix(security) : add ROLE_USER security on all read endpoints Co-Authored-By: Claude Sonnet 4.6 --- src/Entity/Client.php | 4 ++-- src/Entity/Project.php | 4 ++-- src/Entity/Task.php | 4 ++-- src/Entity/TaskDocument.php | 4 ++-- src/Entity/TaskEffort.php | 4 ++-- src/Entity/TaskGroup.php | 4 ++-- src/Entity/TaskPriority.php | 4 ++-- src/Entity/TaskStatus.php | 4 ++-- src/Entity/TaskTag.php | 4 ++-- src/Entity/TimeEntry.php | 5 +++-- 10 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/Entity/Client.php b/src/Entity/Client.php index 41e7551..b98ffad 100644 --- a/src/Entity/Client.php +++ b/src/Entity/Client.php @@ -18,8 +18,8 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(), - new Get(), + new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), new Delete(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/Project.php b/src/Entity/Project.php index 559c4f4..ec19473 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -20,8 +20,8 @@ use Symfony\Component\Validator\Constraints as Assert; #[ApiResource( operations: [ - new GetCollection(), - new Get(), + new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), new Post( security: "is_granted('ROLE_ADMIN')", denormalizationContext: ['groups' => ['project:write', 'project:create']], diff --git a/src/Entity/Task.php b/src/Entity/Task.php index 1a6828c..b80a525 100644 --- a/src/Entity/Task.php +++ b/src/Entity/Task.php @@ -22,8 +22,8 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false), - new Get(), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class), new Patch(security: "is_granted('ROLE_ADMIN')"), new Delete(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/TaskDocument.php b/src/Entity/TaskDocument.php index 9cced98..ada81dd 100644 --- a/src/Entity/TaskDocument.php +++ b/src/Entity/TaskDocument.php @@ -19,8 +19,8 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(paginationEnabled: false), - new Get(), + new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), new Post( security: "is_granted('ROLE_ADMIN')", processor: TaskDocumentProcessor::class, diff --git a/src/Entity/TaskEffort.php b/src/Entity/TaskEffort.php index a9f76da..a656725 100644 --- a/src/Entity/TaskEffort.php +++ b/src/Entity/TaskEffort.php @@ -16,8 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(), - new Get(), + new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), new Delete(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/TaskGroup.php b/src/Entity/TaskGroup.php index ae3294c..753c9d1 100644 --- a/src/Entity/TaskGroup.php +++ b/src/Entity/TaskGroup.php @@ -19,8 +19,8 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(), - new Get(), + new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), new Delete(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/TaskPriority.php b/src/Entity/TaskPriority.php index c6aa263..48da256 100644 --- a/src/Entity/TaskPriority.php +++ b/src/Entity/TaskPriority.php @@ -16,8 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(), - new Get(), + new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), new Delete(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/TaskStatus.php b/src/Entity/TaskStatus.php index 8d6f0b0..0d3d18b 100644 --- a/src/Entity/TaskStatus.php +++ b/src/Entity/TaskStatus.php @@ -16,8 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(), - new Get(), + new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), new Delete(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/TaskTag.php b/src/Entity/TaskTag.php index ac93728..537db3a 100644 --- a/src/Entity/TaskTag.php +++ b/src/Entity/TaskTag.php @@ -16,8 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(), - new Get(), + new GetCollection(security: "is_granted('ROLE_USER')"), + new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_ADMIN')"), new Patch(security: "is_granted('ROLE_ADMIN')"), new Delete(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/Entity/TimeEntry.php b/src/Entity/TimeEntry.php index eeec326..2c5332d 100644 --- a/src/Entity/TimeEntry.php +++ b/src/Entity/TimeEntry.php @@ -24,15 +24,16 @@ use Symfony\Component\Serializer\Attribute\Groups; #[ApiResource( operations: [ - new GetCollection(), + new GetCollection(security: "is_granted('ROLE_USER')"), new GetCollection( name: 'active_time_entry', uriTemplate: '/time_entries/active', provider: ActiveTimeEntryProvider::class, description: 'Get the active timer for the current user', paginationEnabled: false, + security: "is_granted('ROLE_USER')", ), - new Get(), + new Get(security: "is_granted('ROLE_USER')"), new Post(security: "is_granted('ROLE_USER')"), new Patch(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"), new Delete(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"), From 05e24db6ca8bd18f821f6f5731551b351ff9841a Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:21:28 +0100 Subject: [PATCH 08/89] feat(security) : add role hierarchy for client portal Co-Authored-By: Claude Sonnet 4.6 --- config/packages/security.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 17f38d2..2c48546 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -1,4 +1,7 @@ security: + role_hierarchy: + ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT] + # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords password_hashers: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' From 2b9095b1a2d1734c39bc74ba85e7ea648524e548 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:22:42 +0100 Subject: [PATCH 09/89] docs : add MCP server implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-15-mcp-server.md | 2100 +++++++++++++++++ 1 file changed, 2100 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-15-mcp-server.md diff --git a/docs/superpowers/plans/2026-03-15-mcp-server.md b/docs/superpowers/plans/2026-03-15-mcp-server.md new file mode 100644 index 0000000..2c65edd --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-mcp-server.md @@ -0,0 +1,2100 @@ +# MCP Server Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an MCP server to Lesstime exposing projects, tasks, and time tracking for AI clients via STDIO and HTTP transports. + +**Architecture:** Install `symfony/mcp-bundle`, create 21 tool classes in `src/Mcp/Tool/` organized by domain (Project, Task, TaskMeta, TimeEntry, Reference). HTTP transport secured by API token on User entity with a custom Symfony authenticator. STDIO for local Claude Code usage. + +**Tech Stack:** symfony/mcp-bundle, Symfony 8 security (custom authenticator), Doctrine ORM, PHP 8.4 + +**Spec:** `docs/superpowers/specs/2026-03-15-mcp-server-design.md` + +--- + +## Chunk 1: Infrastructure (Bundle, Auth, Config) + +### Task 1: Install symfony/mcp-bundle + +**Files:** +- Modify: `composer.json` +- Create: `config/packages/mcp.yaml` +- Create: `config/routes/mcp.yaml` + +- [ ] **Step 1: Install the bundle via Composer** + +Run inside Docker container: +```bash +docker exec -u www-data php-lesstime-fpm composer require symfony/mcp-bundle +``` + +- [ ] **Step 2: Create MCP config** + +Create `config/packages/mcp.yaml`: +```yaml +mcp: + app: 'lesstime' + version: '1.0.0' + description: 'Lesstime project management — projects, tasks, time tracking' + instructions: | + This server provides access to the Lesstime project management system. + You can list/create/update/delete projects, tasks, and time entries. + Tasks belong to projects and have statuses, priorities, efforts, tags, and groups. + Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects). + Groups are PER-PROJECT (each group belongs to one project). + Time entries track work duration and can be linked to projects and tasks. + Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover + available metadata before creating or updating tasks. + Use list-users and list-clients to discover valid user and client IDs. + client_transports: + stdio: true + http: true + http: + path: /_mcp + session: + store: file + directory: '%kernel.cache_dir%/mcp-sessions' + ttl: 3600 +``` + +- [ ] **Step 3: Create MCP route config** + +Create `config/routes/mcp.yaml`: +```yaml +mcp: + resource: . + type: mcp +``` + +- [ ] **Step 4: Verify the bundle is loaded** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console mcp:server --help +``` +Expected: Help output for the `mcp:server` command (no errors). + +- [ ] **Step 5: Commit** + +```bash +git add composer.json composer.lock symfony.lock config/packages/mcp.yaml config/routes/mcp.yaml +git commit -m "feat : install symfony/mcp-bundle with STDIO + HTTP transport config" +``` + +--- + +### Task 2: Add API token to User entity + +**Files:** +- Modify: `src/Entity/User.php` +- Create: new Doctrine migration + +- [ ] **Step 1: Add apiToken property to User entity** + +In `src/Entity/User.php`, add after the `$createdAt` property: + +```php +#[ORM\Column(length: 64, unique: true, nullable: true)] +private ?string $apiToken = null; +``` + +Add getter and setter after the `eraseCredentials` method: + +```php +public function getApiToken(): ?string +{ + return $this->apiToken; +} + +public function setApiToken(?string $apiToken): static +{ + $this->apiToken = $apiToken; + + return $this; +} +``` + +- [ ] **Step 2: Generate and run the migration** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff +docker exec -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction +``` + +Expected: Migration runs successfully, adds `api_token` column to `user` table. + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/User.php migrations/ +git commit -m "feat : add apiToken field to User entity for MCP HTTP auth" +``` + +--- + +### Task 3: Create API token authenticator + +**Files:** +- Create: `src/Security/ApiTokenAuthenticator.php` +- Modify: `config/packages/security.yaml` + +- [ ] **Step 1: Create the authenticator class** + +Create `src/Security/ApiTokenAuthenticator.php`: + +```php +headers->has('Authorization') + && str_starts_with((string) $request->headers->get('Authorization'), 'Bearer '); + } + + public function authenticate(Request $request): Passport + { + $authHeader = (string) $request->headers->get('Authorization'); + $token = substr($authHeader, 7); // Remove "Bearer " prefix + + if ('' === $token) { + throw new CustomUserMessageAuthenticationException('API token missing.'); + } + + return new SelfValidatingPassport( + new UserBadge($token, function (string $token): ?\App\Entity\User { + $user = $this->userRepository->findOneBy(['apiToken' => $token]); + + if (null === $user) { + throw new CustomUserMessageAuthenticationException('Invalid API token.'); + } + + return $user; + }) + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; // Let the request continue + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return new JsonResponse( + ['error' => $exception->getMessageKey()], + Response::HTTP_UNAUTHORIZED + ); + } +} +``` + +- [ ] **Step 2: Add MCP firewall to security.yaml** + +In `config/packages/security.yaml`, add the `mcp` firewall **before** the `api` firewall: + +```yaml + mcp: + pattern: ^/_mcp + stateless: true + provider: app_user_provider + custom_authenticators: + - App\Security\ApiTokenAuthenticator +``` + +Add access control rule **before** the existing `/api` rules: + +```yaml + - { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY } +``` + +- [ ] **Step 3: Verify the firewall is registered** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console debug:firewall +``` + +Expected: `mcp` firewall appears in the list. + +- [ ] **Step 4: Commit** + +```bash +git add src/Security/ApiTokenAuthenticator.php config/packages/security.yaml +git commit -m "feat : add ApiTokenAuthenticator for MCP HTTP transport" +``` + +--- + +### Task 4: Create generate-api-token command + +**Files:** +- Create: `src/Command/GenerateApiTokenCommand.php` + +- [ ] **Step 1: Create the console command** + +Create `src/Command/GenerateApiTokenCommand.php`: + +```php +addArgument('username', InputArgument::REQUIRED, 'The username to generate a token for'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $username = $input->getArgument('username'); + + $user = $this->userRepository->findOneBy(['username' => $username]); + + if (null === $user) { + $io->error(\sprintf('User "%s" not found.', $username)); + + return Command::FAILURE; + } + + $token = bin2hex(random_bytes(32)); + $user->setApiToken($token); + $this->entityManager->flush(); + + $io->success(\sprintf('API token generated for user "%s":', $username)); + $io->writeln($token); + + return Command::SUCCESS; + } +} +``` + +- [ ] **Step 2: Verify the command works** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token admin +``` + +Expected: Outputs a 64-character hex token. + +- [ ] **Step 3: Commit** + +```bash +git add src/Command/GenerateApiTokenCommand.php +git commit -m "feat : add app:generate-api-token console command" +``` + +--- + +### Task 5: Add Nginx location and update fixtures + +**Files:** +- Modify: `docker/nginx/conf.d/lesstime.conf` +- Modify: `src/DataFixtures/AppFixtures.php` + +- [ ] **Step 1: Add Nginx location block for /_mcp** + +In `docker/nginx/conf.d/lesstime.conf`, add this block **before** the `location ^~ /api/` block: + +```nginx + location ^~ /_mcp { + root /var/www/html/public; + try_files $uri /index.php?$query_string; + } +``` + +- [ ] **Step 2: Add API token to admin fixture** + +In `src/DataFixtures/AppFixtures.php`, in the section where the admin user is created, add after `setPassword(...)`: + +```php +->setApiToken('dev-mcp-token-for-testing-only-do-not-use-in-production') +``` + +- [ ] **Step 3: Restart Nginx and reload fixtures** + +```bash +docker restart nginx-lesstime +docker exec -u www-data php-lesstime-fpm php bin/console doctrine:fixtures:load --no-interaction +``` + +- [ ] **Step 4: Test HTTP transport with curl** + +```bash +curl -s -X POST http://localhost:8082/_mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' +``` + +Expected: JSON-RPC response with server capabilities (or at least not a 401/404). + +- [ ] **Step 5: Test STDIO transport** + +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server +``` + +Expected: JSON-RPC response via stdout. + +- [ ] **Step 6: Commit** + +```bash +git add docker/nginx/conf.d/lesstime.conf src/DataFixtures/AppFixtures.php +git commit -m "feat : add Nginx /_mcp location and API token fixture for MCP" +``` + +--- + +## Chunk 2: Reference & Project Tools + +### Task 6: Reference tools (list-users, list-clients) + +**Files:** +- Create: `src/Mcp/Tool/Reference/ListUsersTool.php` +- Create: `src/Mcp/Tool/Reference/ListClientsTool.php` + +- [ ] **Step 1: Create ListUsersTool** + +Create `src/Mcp/Tool/Reference/ListUsersTool.php`: + +```php +userRepository->findBy([], ['username' => 'ASC']); + + return json_encode(array_map(fn($user) => [ + 'id' => $user->getId(), + 'username' => $user->getUsername(), + ], $users)); + } +} +``` + +- [ ] **Step 2: Create ListClientsTool** + +Create `src/Mcp/Tool/Reference/ListClientsTool.php`: + +```php +clientRepository->findBy([], ['name' => 'ASC']); + + return json_encode(array_map(fn($client) => [ + 'id' => $client->getId(), + 'name' => $client->getName(), + 'email' => $client->getEmail(), + ], $clients)); + } +} +``` + +- [ ] **Step 3: Verify tools are discovered** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console debug:container --tag=mcp.tool 2>/dev/null || docker exec -u www-data php-lesstime-fpm php bin/console mcp:server --help +``` + +Check that both tools appear in the registered MCP tools. + +- [ ] **Step 4: Commit** + +```bash +git add src/Mcp/Tool/Reference/ +git commit -m "feat : add list-users and list-clients MCP tools" +``` + +--- + +### Task 7: Project tools (list, get, create, update) + +**Files:** +- Create: `src/Mcp/Tool/Project/ListProjectsTool.php` +- Create: `src/Mcp/Tool/Project/GetProjectTool.php` +- Create: `src/Mcp/Tool/Project/CreateProjectTool.php` +- Create: `src/Mcp/Tool/Project/UpdateProjectTool.php` + +- [ ] **Step 1: Create ListProjectsTool** + +Create `src/Mcp/Tool/Project/ListProjectsTool.php`: + +```php +projectRepository->findBy(['archived' => $archived], ['name' => 'ASC']); + + return json_encode(array_map(fn($project) => [ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + ], $projects)); + } +} +``` + +- [ ] **Step 2: Create GetProjectTool** + +Create `src/Mcp/Tool/Project/GetProjectTool.php`: + +```php +projectRepository->find($id); + + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $id)); + } + + // Count tasks per status + $qb = $this->taskRepository->createQueryBuilder('t') + ->select('s.label AS statusLabel, COUNT(t.id) AS taskCount') + ->leftJoin('t.status', 's') + ->where('t.project = :project') + ->setParameter('project', $project) + ->groupBy('s.id, s.label'); + + $statusCounts = []; + $totalTasks = 0; + foreach ($qb->getQuery()->getResult() as $row) { + $label = $row['statusLabel'] ?? 'No status'; + $count = (int) $row['taskCount']; + $statusCounts[$label] = $count; + $totalTasks += $count; + } + + return json_encode([ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + 'taskSummary' => $statusCounts, + 'totalTasks' => $totalTasks, + ]); + } +} +``` + +- [ ] **Step 3: Create CreateProjectTool** + +Create `src/Mcp/Tool/Project/CreateProjectTool.php`: + +```php +setName($name); + $project->setCode($code); + + if (null !== $description) { + $project->setDescription($description); + } + if (null !== $color) { + $project->setColor($color); + } + if (null !== $clientId) { + $client = $this->clientRepository->find($clientId); + if (null === $client) { + throw new \InvalidArgumentException(\sprintf('Client with ID %d not found.', $clientId)); + } + $project->setClient($client); + } + + $this->entityManager->persist($project); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + ]); + } +} +``` + +- [ ] **Step 4: Create UpdateProjectTool** + +Create `src/Mcp/Tool/Project/UpdateProjectTool.php`: + +```php +projectRepository->find($id); + + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $id)); + } + + if (null !== $name) { + $project->setName($name); + } + if (null !== $code) { + $project->setCode($code); + } + if (null !== $description) { + $project->setDescription($description); + } + if (null !== $color) { + $project->setColor($color); + } + if (null !== $clientId) { + $client = $this->clientRepository->find($clientId); + if (null === $client) { + throw new \InvalidArgumentException(\sprintf('Client with ID %d not found.', $clientId)); + } + $project->setClient($client); + } + if (null !== $archived) { + $project->setArchived($archived); + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + ]); + } +} +``` + +- [ ] **Step 5: Test with STDIO** + +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server +``` + +Expected: JSON response listing all registered tools including `list-projects`, `get-project`, `create-project`, `update-project`. + +- [ ] **Step 6: Commit** + +```bash +git add src/Mcp/Tool/Project/ src/Mcp/Tool/Reference/ +git commit -m "feat : add project and reference MCP tools (list/get/create/update)" +``` + +--- + +## Chunk 3: Task Tools + +### Task 8: List and get task tools + +**Files:** +- Create: `src/Mcp/Tool/Task/ListTasksTool.php` +- Create: `src/Mcp/Tool/Task/GetTaskTool.php` + +- [ ] **Step 1: Create ListTasksTool** + +Create `src/Mcp/Tool/Task/ListTasksTool.php`: + +```php +taskRepository->createQueryBuilder('t') + ->leftJoin('t.status', 's')->addSelect('s') + ->leftJoin('t.priority', 'p')->addSelect('p') + ->leftJoin('t.assignee', 'a')->addSelect('a') + ->leftJoin('t.project', 'pr')->addSelect('pr') + ->leftJoin('t.effort', 'e')->addSelect('e') + ->leftJoin('t.group', 'g')->addSelect('g') + ->leftJoin('t.tags', 'tg')->addSelect('tg') + ->where('t.archived = :archived') + ->setParameter('archived', $archived) + ->orderBy('t.id', 'DESC') + ->setMaxResults($limit); + + if (null !== $projectId) { + $qb->andWhere('pr.id = :projectId')->setParameter('projectId', $projectId); + } + if (null !== $statusId) { + $qb->andWhere('s.id = :statusId')->setParameter('statusId', $statusId); + } + if (null !== $assigneeId) { + $qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId); + } + if (null !== $priorityId) { + $qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId); + } + if (null !== $groupId) { + $qb->andWhere('t.group = :groupId')->setParameter('groupId', $groupId); + } + + $tasks = $qb->getQuery()->getResult(); + + if (null !== $tagIds) { + $tasks = array_filter($tasks, function ($task) use ($tagIds) { + $taskTagIds = $task->getTags()->map(fn($t) => $t->getId())->toArray(); + + return !empty(array_intersect($tagIds, $taskTagIds)); + }); + } + + return json_encode(array_map(fn($task) => [ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'status' => $task->getStatus() ? [ + 'id' => $task->getStatus()->getId(), + 'label' => $task->getStatus()->getLabel(), + 'color' => $task->getStatus()->getColor(), + ] : null, + 'priority' => $task->getPriority() ? [ + 'id' => $task->getPriority()->getId(), + 'label' => $task->getPriority()->getLabel(), + 'color' => $task->getPriority()->getColor(), + ] : null, + 'assignee' => $task->getAssignee() ? [ + 'id' => $task->getAssignee()->getId(), + 'username' => $task->getAssignee()->getUsername(), + ] : null, + 'effort' => $task->getEffort() ? [ + 'id' => $task->getEffort()->getId(), + 'label' => $task->getEffort()->getLabel(), + ] : null, + 'group' => $task->getGroup() ? [ + 'id' => $task->getGroup()->getId(), + 'title' => $task->getGroup()->getTitle(), + ] : null, + 'project' => [ + 'id' => $task->getProject()->getId(), + 'code' => $task->getProject()->getCode(), + 'name' => $task->getProject()->getName(), + ], + 'tags' => $task->getTags()->map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), + 'archived' => $task->isArchived(), + ], array_values($tasks))); + } +} +``` + +- [ ] **Step 2: Create GetTaskTool** + +Create `src/Mcp/Tool/Task/GetTaskTool.php`: + +```php +taskRepository->find($id); + + if (null === $task) { + throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $id)); + } + + return json_encode([ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'description' => $task->getDescription(), + 'status' => $task->getStatus() ? [ + 'id' => $task->getStatus()->getId(), + 'label' => $task->getStatus()->getLabel(), + 'color' => $task->getStatus()->getColor(), + 'isFinal' => $task->getStatus()->isFinal(), + ] : null, + 'priority' => $task->getPriority() ? [ + 'id' => $task->getPriority()->getId(), + 'label' => $task->getPriority()->getLabel(), + 'color' => $task->getPriority()->getColor(), + ] : null, + 'effort' => $task->getEffort() ? [ + 'id' => $task->getEffort()->getId(), + 'label' => $task->getEffort()->getLabel(), + ] : null, + 'assignee' => $task->getAssignee() ? [ + 'id' => $task->getAssignee()->getId(), + 'username' => $task->getAssignee()->getUsername(), + ] : null, + 'group' => $task->getGroup() ? [ + 'id' => $task->getGroup()->getId(), + 'title' => $task->getGroup()->getTitle(), + 'color' => $task->getGroup()->getColor(), + ] : null, + 'project' => [ + 'id' => $task->getProject()->getId(), + 'code' => $task->getProject()->getCode(), + 'name' => $task->getProject()->getName(), + ], + 'tags' => $task->getTags()->map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + 'color' => $t->getColor(), + ])->toArray(), + 'documents' => $task->getDocuments()->map(fn($doc) => [ + 'id' => $doc->getId(), + 'originalName' => $doc->getOriginalName(), + 'mimeType' => $doc->getMimeType(), + 'size' => $doc->getSize(), + 'createdAt' => $doc->getCreatedAt()?->format('c'), + 'uploadedBy' => $doc->getUploadedBy() ? [ + 'id' => $doc->getUploadedBy()->getId(), + 'username' => $doc->getUploadedBy()->getUsername(), + ] : null, + ])->toArray(), + 'archived' => $task->isArchived(), + ]); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Mcp/Tool/Task/ListTasksTool.php src/Mcp/Tool/Task/GetTaskTool.php +git commit -m "feat : add list-tasks and get-task MCP tools" +``` + +--- + +### Task 9: Create, update, delete task tools + +**Files:** +- Create: `src/Mcp/Tool/Task/CreateTaskTool.php` +- Create: `src/Mcp/Tool/Task/UpdateTaskTool.php` +- Create: `src/Mcp/Tool/Task/DeleteTaskTool.php` + +- [ ] **Step 1: Create CreateTaskTool** + +Create `src/Mcp/Tool/Task/CreateTaskTool.php`: + +```php +projectRepository->find($projectId); + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId)); + } + + $task = new Task(); + $task->setProject($project); + $task->setTitle($title); + $task->setNumber($this->taskRepository->findMaxNumberByProject($project) + 1); + + if (null !== $description) { + $task->setDescription($description); + } + if (null !== $statusId) { + $status = $this->taskStatusRepository->find($statusId); + if (null === $status) { + throw new \InvalidArgumentException(\sprintf('TaskStatus with ID %d not found.', $statusId)); + } + $task->setStatus($status); + } + if (null !== $priorityId) { + $priority = $this->taskPriorityRepository->find($priorityId); + if (null === $priority) { + throw new \InvalidArgumentException(\sprintf('TaskPriority with ID %d not found.', $priorityId)); + } + $task->setPriority($priority); + } + if (null !== $effortId) { + $effort = $this->taskEffortRepository->find($effortId); + if (null === $effort) { + throw new \InvalidArgumentException(\sprintf('TaskEffort with ID %d not found.', $effortId)); + } + $task->setEffort($effort); + } + if (null !== $assigneeId) { + $assignee = $this->userRepository->find($assigneeId); + if (null === $assignee) { + throw new \InvalidArgumentException(\sprintf('User with ID %d not found.', $assigneeId)); + } + $task->setAssignee($assignee); + } + if (null !== $groupId) { + $group = $this->taskGroupRepository->find($groupId); + if (null === $group) { + throw new \InvalidArgumentException(\sprintf('TaskGroup with ID %d not found.', $groupId)); + } + $task->setGroup($group); + } + if (null !== $tagIds) { + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId)); + } + $task->addTag($tag); + } + } + + $this->entityManager->persist($task); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'project' => [ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + ], + 'status' => $task->getStatus() ? ['id' => $task->getStatus()->getId(), 'label' => $task->getStatus()->getLabel()] : null, + 'priority' => $task->getPriority() ? ['id' => $task->getPriority()->getId(), 'label' => $task->getPriority()->getLabel()] : null, + 'effort' => $task->getEffort() ? ['id' => $task->getEffort()->getId(), 'label' => $task->getEffort()->getLabel()] : null, + 'assignee' => $task->getAssignee() ? ['id' => $task->getAssignee()->getId(), 'username' => $task->getAssignee()->getUsername()] : null, + 'group' => $task->getGroup() ? ['id' => $task->getGroup()->getId(), 'title' => $task->getGroup()->getTitle()] : null, + ]); + } +} +``` + +- [ ] **Step 2: Create UpdateTaskTool** + +Create `src/Mcp/Tool/Task/UpdateTaskTool.php`: + +```php +taskRepository->find($id); + + if (null === $task) { + throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $id)); + } + + if (null !== $title) { + $task->setTitle($title); + } + if (null !== $description) { + $task->setDescription($description); + } + if (null !== $statusId) { + $status = $this->taskStatusRepository->find($statusId); + if (null === $status) { + throw new \InvalidArgumentException(\sprintf('TaskStatus with ID %d not found.', $statusId)); + } + $task->setStatus($status); + } + if (null !== $priorityId) { + $priority = $this->taskPriorityRepository->find($priorityId); + if (null === $priority) { + throw new \InvalidArgumentException(\sprintf('TaskPriority with ID %d not found.', $priorityId)); + } + $task->setPriority($priority); + } + if (null !== $effortId) { + $effort = $this->taskEffortRepository->find($effortId); + if (null === $effort) { + throw new \InvalidArgumentException(\sprintf('TaskEffort with ID %d not found.', $effortId)); + } + $task->setEffort($effort); + } + if (null !== $assigneeId) { + $assignee = $this->userRepository->find($assigneeId); + if (null === $assignee) { + throw new \InvalidArgumentException(\sprintf('User with ID %d not found.', $assigneeId)); + } + $task->setAssignee($assignee); + } + if (null !== $groupId) { + $group = $this->taskGroupRepository->find($groupId); + if (null === $group) { + throw new \InvalidArgumentException(\sprintf('TaskGroup with ID %d not found.', $groupId)); + } + $task->setGroup($group); + } + if (null !== $tagIds) { + // Clear existing tags and set new ones + foreach ($task->getTags()->toArray() as $existingTag) { + $task->removeTag($existingTag); + } + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId)); + } + $task->addTag($tag); + } + } + if (null !== $archived) { + $task->setArchived($archived); + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'project' => [ + 'id' => $task->getProject()->getId(), + 'code' => $task->getProject()->getCode(), + ], + 'status' => $task->getStatus() ? ['id' => $task->getStatus()->getId(), 'label' => $task->getStatus()->getLabel()] : null, + 'priority' => $task->getPriority() ? ['id' => $task->getPriority()->getId(), 'label' => $task->getPriority()->getLabel()] : null, + 'assignee' => $task->getAssignee() ? ['id' => $task->getAssignee()->getId(), 'username' => $task->getAssignee()->getUsername()] : null, + 'archived' => $task->isArchived(), + ]); + } +} +``` + +- [ ] **Step 3: Create DeleteTaskTool** + +Create `src/Mcp/Tool/Task/DeleteTaskTool.php`: + +```php +taskRepository->find($id); + + if (null === $task) { + throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $id)); + } + + $taskCode = $task->getProject()->getCode() . '-' . $task->getNumber(); + $this->entityManager->remove($task); + $this->entityManager->flush(); + + return json_encode([ + 'success' => true, + 'message' => \sprintf('Task %s deleted.', $taskCode), + ]); + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Mcp/Tool/Task/ +git commit -m "feat : add create-task, update-task, delete-task MCP tools" +``` + +--- + +## Chunk 4: TaskMeta & TimeEntry Tools + +### Task 10: TaskMeta tools (statuses, priorities, efforts, tags, groups) + +**Files:** +- Create: `src/Mcp/Tool/TaskMeta/ListStatusesTool.php` +- Create: `src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php` +- Create: `src/Mcp/Tool/TaskMeta/ListEffortsTool.php` +- Create: `src/Mcp/Tool/TaskMeta/ListTagsTool.php` +- Create: `src/Mcp/Tool/TaskMeta/ListGroupsTool.php` +- Create: `src/Mcp/Tool/TaskMeta/CreateGroupTool.php` +- Create: `src/Mcp/Tool/TaskMeta/UpdateGroupTool.php` + +- [ ] **Step 1: Create ListStatusesTool** + +Create `src/Mcp/Tool/TaskMeta/ListStatusesTool.php`: + +```php +taskStatusRepository->findBy([], ['position' => 'ASC']); + + return json_encode(array_map(fn($s) => [ + 'id' => $s->getId(), + 'label' => $s->getLabel(), + 'color' => $s->getColor(), + 'position' => $s->getPosition(), + 'isFinal' => $s->isFinal(), + ], $statuses)); + } +} +``` + +- [ ] **Step 2: Create ListPrioritiesTool** + +Create `src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php`: + +```php +taskPriorityRepository->findBy([], ['label' => 'ASC']); + + return json_encode(array_map(fn($p) => [ + 'id' => $p->getId(), + 'label' => $p->getLabel(), + 'color' => $p->getColor(), + ], $priorities)); + } +} +``` + +- [ ] **Step 3: Create ListEffortsTool** + +Create `src/Mcp/Tool/TaskMeta/ListEffortsTool.php`: + +```php +taskEffortRepository->findBy([], ['label' => 'ASC']); + + return json_encode(array_map(fn($e) => [ + 'id' => $e->getId(), + 'label' => $e->getLabel(), + ], $efforts)); + } +} +``` + +- [ ] **Step 4: Create ListTagsTool** + +Create `src/Mcp/Tool/TaskMeta/ListTagsTool.php`: + +```php +taskTagRepository->findBy([], ['label' => 'ASC']); + + return json_encode(array_map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + 'color' => $t->getColor(), + ], $tags)); + } +} +``` + +- [ ] **Step 5: Create ListGroupsTool** + +Create `src/Mcp/Tool/TaskMeta/ListGroupsTool.php`: + +```php + $archived]; + if (null !== $projectId) { + $criteria['project'] = $projectId; + } + + $groups = $this->taskGroupRepository->findBy($criteria, ['title' => 'ASC']); + + return json_encode(array_map(fn($g) => [ + 'id' => $g->getId(), + 'title' => $g->getTitle(), + 'description' => $g->getDescription(), + 'color' => $g->getColor(), + 'project' => [ + 'id' => $g->getProject()->getId(), + 'code' => $g->getProject()->getCode(), + 'name' => $g->getProject()->getName(), + ], + 'archived' => $g->isArchived(), + ], $groups)); + } +} +``` + +- [ ] **Step 6: Create CreateGroupTool** + +Create `src/Mcp/Tool/TaskMeta/CreateGroupTool.php`: + +```php +projectRepository->find($projectId); + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId)); + } + + $group = new TaskGroup(); + $group->setProject($project); + $group->setTitle($title); + + if (null !== $description) { + $group->setDescription($description); + } + if (null !== $color) { + $group->setColor($color); + } + + $this->entityManager->persist($group); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $group->getId(), + 'title' => $group->getTitle(), + 'description' => $group->getDescription(), + 'color' => $group->getColor(), + 'project' => [ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + ], + 'archived' => $group->isArchived(), + ]); + } +} +``` + +- [ ] **Step 7: Create UpdateGroupTool** + +Create `src/Mcp/Tool/TaskMeta/UpdateGroupTool.php`: + +```php +taskGroupRepository->find($id); + + if (null === $group) { + throw new \InvalidArgumentException(\sprintf('TaskGroup with ID %d not found.', $id)); + } + + if (null !== $title) { + $group->setTitle($title); + } + if (null !== $description) { + $group->setDescription($description); + } + if (null !== $color) { + $group->setColor($color); + } + if (null !== $archived) { + $group->setArchived($archived); + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $group->getId(), + 'title' => $group->getTitle(), + 'description' => $group->getDescription(), + 'color' => $group->getColor(), + 'project' => [ + 'id' => $group->getProject()->getId(), + 'code' => $group->getProject()->getCode(), + 'name' => $group->getProject()->getName(), + ], + 'archived' => $group->isArchived(), + ]); + } +} +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/Mcp/Tool/TaskMeta/ +git commit -m "feat : add task metadata MCP tools (statuses, priorities, efforts, tags, groups CRUD)" +``` + +--- + +### Task 11: TimeEntry tools + +**Files:** +- Create: `src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php` +- Create: `src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php` +- Create: `src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php` +- Create: `src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php` + +- [ ] **Step 1: Create ListTimeEntriesTool** + +Create `src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php`: + +```php +timeEntryRepository->createQueryBuilder('te') + ->leftJoin('te.user', 'u')->addSelect('u') + ->leftJoin('te.project', 'p')->addSelect('p') + ->leftJoin('te.task', 't')->addSelect('t') + ->leftJoin('te.tags', 'tg')->addSelect('tg') + ->orderBy('te.startedAt', 'DESC') + ->setMaxResults($limit); + + if (null !== $userId) { + $qb->andWhere('u.id = :userId')->setParameter('userId', $userId); + } + if (null !== $projectId) { + $qb->andWhere('p.id = :projectId')->setParameter('projectId', $projectId); + } + if (null !== $taskId) { + $qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId); + } + if (null !== $startDate) { + $qb->andWhere('te.startedAt >= :startDate') + ->setParameter('startDate', new \DateTimeImmutable($startDate . ' 00:00:00')); + } + if (null !== $endDate) { + $qb->andWhere('te.startedAt <= :endDate') + ->setParameter('endDate', new \DateTimeImmutable($endDate . ' 23:59:59')); + } + + $entries = $qb->getQuery()->getResult(); + + return json_encode(array_map(fn($entry) => [ + 'id' => $entry->getId(), + 'title' => $entry->getTitle(), + 'description' => $entry->getDescription(), + 'startedAt' => $entry->getStartedAt()?->format('c'), + 'stoppedAt' => $entry->getStoppedAt()?->format('c'), + 'duration' => $entry->getStoppedAt() && $entry->getStartedAt() + ? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60) + : null, + 'user' => [ + 'id' => $entry->getUser()->getId(), + 'username' => $entry->getUser()->getUsername(), + ], + 'project' => $entry->getProject() ? [ + 'id' => $entry->getProject()->getId(), + 'code' => $entry->getProject()->getCode(), + 'name' => $entry->getProject()->getName(), + ] : null, + 'task' => $entry->getTask() ? [ + 'id' => $entry->getTask()->getId(), + 'number' => $entry->getTask()->getNumber(), + 'title' => $entry->getTask()->getTitle(), + ] : null, + 'tags' => $entry->getTags()->map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), + ], $entries)); + } +} +``` + +- [ ] **Step 2: Create CreateTimeEntryTool** + +Create `src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php`: + +```php +userRepository->find($userId); + if (null === $user) { + throw new \InvalidArgumentException(\sprintf('User with ID %d not found.', $userId)); + } + + // Check for existing active timer if creating a new active one + if (null === $stoppedAt) { + $activeEntry = $this->timeEntryRepository->findActiveByUser($user); + if (null !== $activeEntry) { + throw new \InvalidArgumentException(\sprintf('User "%s" already has an active timer (ID %d). Stop it before starting a new one.', $user->getUsername(), $activeEntry->getId())); + } + } + + $entry = new TimeEntry(); + $entry->setUser($user); + $entry->setStartedAt(new \DateTimeImmutable($startedAt)); + + if (null !== $title) { + $entry->setTitle($title); + } + if (null !== $stoppedAt) { + $entry->setStoppedAt(new \DateTimeImmutable($stoppedAt)); + } + if (null !== $description) { + $entry->setDescription($description); + } + if (null !== $projectId) { + $project = $this->projectRepository->find($projectId); + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId)); + } + $entry->setProject($project); + } + if (null !== $taskId) { + $task = $this->taskRepository->find($taskId); + if (null === $task) { + throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $taskId)); + } + $entry->setTask($task); + } + if (null !== $tagIds) { + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId)); + } + $entry->addTag($tag); + } + } + + $this->entityManager->persist($entry); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $entry->getId(), + 'title' => $entry->getTitle(), + 'startedAt' => $entry->getStartedAt()?->format('c'), + 'stoppedAt' => $entry->getStoppedAt()?->format('c'), + 'user' => ['id' => $user->getId(), 'username' => $user->getUsername()], + 'project' => $entry->getProject() ? ['id' => $entry->getProject()->getId(), 'code' => $entry->getProject()->getCode()] : null, + 'task' => $entry->getTask() ? ['id' => $entry->getTask()->getId(), 'number' => $entry->getTask()->getNumber()] : null, + ]); + } +} +``` + +- [ ] **Step 3: Create UpdateTimeEntryTool** + +Create `src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php`: + +```php +timeEntryRepository->find($id); + + if (null === $entry) { + throw new \InvalidArgumentException(\sprintf('TimeEntry with ID %d not found.', $id)); + } + + if (null !== $title) { + $entry->setTitle($title); + } + if (null !== $startedAt) { + $entry->setStartedAt(new \DateTimeImmutable($startedAt)); + } + if (null !== $stoppedAt) { + $entry->setStoppedAt(new \DateTimeImmutable($stoppedAt)); + } + if (null !== $description) { + $entry->setDescription($description); + } + if (null !== $projectId) { + $project = $this->projectRepository->find($projectId); + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId)); + } + $entry->setProject($project); + } + if (null !== $taskId) { + $task = $this->taskRepository->find($taskId); + if (null === $task) { + throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $taskId)); + } + $entry->setTask($task); + } + if (null !== $tagIds) { + foreach ($entry->getTags()->toArray() as $existingTag) { + $entry->removeTag($existingTag); + } + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId)); + } + $entry->addTag($tag); + } + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $entry->getId(), + 'title' => $entry->getTitle(), + 'startedAt' => $entry->getStartedAt()?->format('c'), + 'stoppedAt' => $entry->getStoppedAt()?->format('c'), + 'duration' => $entry->getStoppedAt() && $entry->getStartedAt() + ? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60) + : null, + 'user' => ['id' => $entry->getUser()->getId(), 'username' => $entry->getUser()->getUsername()], + 'project' => $entry->getProject() ? ['id' => $entry->getProject()->getId(), 'code' => $entry->getProject()->getCode()] : null, + 'task' => $entry->getTask() ? ['id' => $entry->getTask()->getId(), 'number' => $entry->getTask()->getNumber()] : null, + ]); + } +} +``` + +- [ ] **Step 4: Create DeleteTimeEntryTool** + +Create `src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php`: + +```php +timeEntryRepository->find($id); + + if (null === $entry) { + throw new \InvalidArgumentException(\sprintf('TimeEntry with ID %d not found.', $id)); + } + + $this->entityManager->remove($entry); + $this->entityManager->flush(); + + return json_encode([ + 'success' => true, + 'message' => 'Time entry deleted.', + ]); + } +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/Mcp/Tool/TimeEntry/ +git commit -m "feat : add time entry MCP tools (list, create, update, delete)" +``` + +--- + +## Chunk 5: Integration Testing & Claude Code Setup + +### Task 12: End-to-end verification + +- [ ] **Step 1: Clear cache and verify all tools are registered** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console cache:clear +``` + +Then list all tools via STDIO: +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server +``` + +Expected: JSON response listing all 21 tools: `list-users`, `list-clients`, `list-projects`, `get-project`, `create-project`, `update-project`, `list-tasks`, `get-task`, `create-task`, `update-task`, `delete-task`, `list-statuses`, `list-priorities`, `list-efforts`, `list-tags`, `list-groups`, `create-group`, `update-group`, `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry` (note: delete-time-entry = 22nd tool, but spec counts 21 — recheck). + +- [ ] **Step 2: Test a tool call via STDIO** + +```bash +echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list-projects","arguments":{}}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server +``` + +Expected: JSON response with the list of fixture projects (SIRH, CRM, ERP, Site vitrine). + +- [ ] **Step 3: Test HTTP transport with auth** + +```bash +docker restart nginx-lesstime +``` + +Initialize MCP session: +```bash +curl -s -X POST http://localhost:8082/_mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' +``` + +Expected: JSON-RPC response with server info and capabilities. + +- [ ] **Step 4: Test HTTP auth rejection** + +```bash +curl -s -X POST http://localhost:8082/_mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer wrong-token" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' +``` + +Expected: 401 Unauthorized response. + +- [ ] **Step 5: Configure Claude Code (STDIO)** + +Add to `.claude/settings.json` (or project-level `.claude/settings.local.json`): + +```json +{ + "mcpServers": { + "lesstime": { + "command": "docker", + "args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"], + "cwd": "/home/r-dev/Lesstime" + } + } +} +``` + +- [ ] **Step 6: Run PHP CS Fixer on all new files** + +```bash +docker exec -u www-data php-lesstime-fpm vendor/bin/php-cs-fixer fix src/Mcp/ --rules=@Symfony,@PSR12 --allow-risky=yes +docker exec -u www-data php-lesstime-fpm vendor/bin/php-cs-fixer fix src/Security/ApiTokenAuthenticator.php --rules=@Symfony,@PSR12 --allow-risky=yes +docker exec -u www-data php-lesstime-fpm vendor/bin/php-cs-fixer fix src/Command/GenerateApiTokenCommand.php --rules=@Symfony,@PSR12 --allow-risky=yes +``` + +- [ ] **Step 7: Final commit if CS Fixer changed anything** + +```bash +git add -A src/Mcp/ src/Security/ src/Command/ +git diff --cached --quiet || git commit -m "style : apply PHP CS Fixer to MCP server code" +``` From 87ab281099d26fa13a9dd9bc537eb38cae5ce2db Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:23:10 +0100 Subject: [PATCH 10/89] feat : extend User entity with client and allowedProjects Co-Authored-By: Claude Sonnet 4.6 --- src/Entity/User.php | 54 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/src/Entity/User.php b/src/Entity/User.php index 5080d65..ea17f47 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -14,6 +14,8 @@ use App\Repository\UserRepository; use App\State\MeProvider; use App\State\UserPasswordHasherProcessor; use DateTimeImmutable; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; @@ -46,11 +48,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read'])] + #[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])] private ?int $id = null; #[ORM\Column(length: 180, unique: true)] - #[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read'])] + #[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'client_ticket:read'])] private ?string $username = null; /** @var list */ @@ -65,9 +67,21 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] private ?DateTimeImmutable $createdAt = null; + #[ORM\ManyToOne(targetEntity: Client::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + #[Groups(['me:read', 'user:list', 'user:write'])] + private ?Client $client = null; + + /** @var Collection */ + #[ORM\ManyToMany(targetEntity: Project::class)] + #[ORM\JoinTable(name: 'user_allowed_projects')] + #[Groups(['me:read', 'user:list', 'user:write'])] + private Collection $allowedProjects; + public function __construct() { - $this->createdAt = new DateTimeImmutable(); + $this->createdAt = new DateTimeImmutable(); + $this->allowedProjects = new ArrayCollection(); } public function getId(): ?int @@ -136,5 +150,39 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } + public function getClient(): ?Client + { + return $this->client; + } + + public function setClient(?Client $client): static + { + $this->client = $client; + + return $this; + } + + /** @return Collection */ + public function getAllowedProjects(): Collection + { + return $this->allowedProjects; + } + + public function addAllowedProject(Project $project): static + { + if (!$this->allowedProjects->contains($project)) { + $this->allowedProjects->add($project); + } + + return $this; + } + + public function removeAllowedProject(Project $project): static + { + $this->allowedProjects->removeElement($project); + + return $this; + } + public function eraseCredentials(): void {} } From 97dcff85425488ee133bb329a508a2b8016b84e5 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:23:36 +0100 Subject: [PATCH 11/89] feat : add ClientTicket entity Co-Authored-By: Claude Sonnet 4.6 --- src/Entity/ClientTicket.php | 261 ++++++++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 src/Entity/ClientTicket.php diff --git a/src/Entity/ClientTicket.php b/src/Entity/ClientTicket.php new file mode 100644 index 0000000..ac7d5db --- /dev/null +++ b/src/Entity/ClientTicket.php @@ -0,0 +1,261 @@ + ['client_ticket:read']], + denormalizationContext: ['groups' => ['client_ticket:write']], + order: ['createdAt' => 'DESC'], +)] +#[ORM\Entity(repositoryClass: ClientTicketRepository::class)] +#[ORM\Table( + name: 'client_ticket', + uniqueConstraints: [ + new ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number']), + ], +)] +class ClientTicket +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['client_ticket:read', 'task:read'])] + private ?int $id = null; + + #[ORM\Column(type: 'integer')] + #[Groups(['client_ticket:read', 'task:read'])] + private ?int $number = null; + + #[ORM\Column(length: 20)] + #[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])] + private ?string $type = null; + + #[ORM\Column(length: 255)] + #[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])] + private ?string $title = null; + + #[ORM\Column(type: 'text')] + #[Groups(['client_ticket:read', 'client_ticket:write'])] + private ?string $description = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['client_ticket:read', 'client_ticket:write'])] + private ?string $url = null; + + #[ORM\Column(length: 20)] + #[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])] + private ?string $status = 'new'; + + #[ORM\Column(type: 'text', nullable: true)] + #[Groups(['client_ticket:read', 'client_ticket:write'])] + private ?string $statusComment = null; + + #[ORM\ManyToOne(targetEntity: Project::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[Groups(['client_ticket:read', 'client_ticket:write'])] + private ?Project $project = null; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + #[Groups(['client_ticket:read'])] + private ?User $submittedBy = null; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: TaskDocument::class, mappedBy: 'clientTicket', cascade: ['remove'])] + #[Groups(['client_ticket:read'])] + private Collection $documents; + + #[ORM\Column(type: 'datetime_immutable')] + #[Groups(['client_ticket:read'])] + private ?DateTimeImmutable $createdAt = null; + + #[ORM\Column(type: 'datetime_immutable')] + #[Groups(['client_ticket:read'])] + private ?DateTimeImmutable $updatedAt = null; + + public function __construct() + { + $this->documents = new ArrayCollection(); + } + + 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 getType(): ?string + { + return $this->type; + } + + public function setType(string $type): static + { + $this->type = $type; + + return $this; + } + + 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 getStatus(): ?string + { + return $this->status; + } + + public function setStatus(string $status): static + { + $this->status = $status; + + return $this; + } + + public function getStatusComment(): ?string + { + return $this->statusComment; + } + + public function setStatusComment(?string $statusComment): static + { + $this->statusComment = $statusComment; + + return $this; + } + + public function getProject(): ?Project + { + return $this->project; + } + + public function setProject(?Project $project): static + { + $this->project = $project; + + return $this; + } + + public function getSubmittedBy(): ?User + { + return $this->submittedBy; + } + + public function setSubmittedBy(?User $submittedBy): static + { + $this->submittedBy = $submittedBy; + + return $this; + } + + /** @return Collection */ + public function getDocuments(): Collection + { + return $this->documents; + } + + public function getCreatedAt(): ?DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getUpdatedAt(): ?DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(DateTimeImmutable $updatedAt): static + { + $this->updatedAt = $updatedAt; + + return $this; + } +} From a538bb360137a8c07f30a96133a18c5230a53ae5 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:23:50 +0100 Subject: [PATCH 12/89] feat : add clientTicket relation to Task entity Co-Authored-By: Claude Sonnet 4.6 --- src/Entity/Task.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Entity/Task.php b/src/Entity/Task.php index b80a525..0e5ef5d 100644 --- a/src/Entity/Task.php +++ b/src/Entity/Task.php @@ -104,6 +104,11 @@ class Task #[Groups(['task:read'])] private Collection $documents; + #[ORM\ManyToOne(targetEntity: ClientTicket::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + #[Groups(['task:read', 'task:write'])] + private ?ClientTicket $clientTicket = null; + public function __construct() { $this->tags = new ArrayCollection(); @@ -262,4 +267,16 @@ class Task { return $this->documents; } + + public function getClientTicket(): ?ClientTicket + { + return $this->clientTicket; + } + + public function setClientTicket(?ClientTicket $clientTicket): static + { + $this->clientTicket = $clientTicket; + + return $this; + } } From 926d6d54c5b568248bf878ea0ca69d038c767c74 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:25:02 +0100 Subject: [PATCH 13/89] feat : generalize TaskDocument for client tickets Co-Authored-By: Claude Sonnet 4.6 --- src/Entity/TaskDocument.php | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/Entity/TaskDocument.php b/src/Entity/TaskDocument.php index ada81dd..df03cbd 100644 --- a/src/Entity/TaskDocument.php +++ b/src/Entity/TaskDocument.php @@ -40,37 +40,42 @@ class TaskDocument #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['task_document:read', 'task:read'])] + #[Groups(['task_document:read', 'task:read', 'client_ticket:read'])] private ?int $id = null; #[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')] - #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')] #[Groups(['task_document:read', 'task_document:write'])] private ?Task $task = null; + #[ORM\ManyToOne(targetEntity: ClientTicket::class, inversedBy: 'documents')] + #[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')] + #[Groups(['task_document:read', 'task_document:write'])] + private ?ClientTicket $clientTicket = null; + #[ORM\Column(length: 255)] - #[Groups(['task_document:read', 'task:read'])] + #[Groups(['task_document:read', 'task:read', 'client_ticket:read'])] private ?string $originalName = null; #[ORM\Column(length: 255)] - #[Groups(['task_document:read', 'task:read'])] + #[Groups(['task_document:read', 'task:read', 'client_ticket:read'])] private ?string $fileName = null; #[ORM\Column(length: 100)] - #[Groups(['task_document:read', 'task:read'])] + #[Groups(['task_document:read', 'task:read', 'client_ticket:read'])] private ?string $mimeType = null; #[ORM\Column] - #[Groups(['task_document:read', 'task:read'])] + #[Groups(['task_document:read', 'task:read', 'client_ticket:read'])] private ?int $size = null; #[ORM\Column(type: 'datetime_immutable')] - #[Groups(['task_document:read', 'task:read'])] + #[Groups(['task_document:read', 'task:read', 'client_ticket:read'])] private ?DateTimeImmutable $createdAt = null; #[ORM\ManyToOne(targetEntity: User::class)] #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] - #[Groups(['task_document:read', 'task:read'])] + #[Groups(['task_document:read', 'task:read', 'client_ticket:read'])] private ?User $uploadedBy = null; public function getId(): ?int @@ -161,4 +166,16 @@ class TaskDocument return $this; } + + public function getClientTicket(): ?ClientTicket + { + return $this->clientTicket; + } + + public function setClientTicket(?ClientTicket $clientTicket): static + { + $this->clientTicket = $clientTicket; + + return $this; + } } From 10cde5e2f9562a8378bd6fc164c11b165cf31d34 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:25:36 +0100 Subject: [PATCH 14/89] feat : add client portal migration Co-Authored-By: Claude Sonnet 4.6 --- migrations/Version20260315182512.php | 69 ++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 migrations/Version20260315182512.php diff --git a/migrations/Version20260315182512.php b/migrations/Version20260315182512.php new file mode 100644 index 0000000..8cade76 --- /dev/null +++ b/migrations/Version20260315182512.php @@ -0,0 +1,69 @@ +addSql('CREATE TABLE client_ticket (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, number INT NOT NULL, type VARCHAR(20) NOT NULL, title VARCHAR(255) NOT NULL, description TEXT NOT NULL, url VARCHAR(255) DEFAULT NULL, status VARCHAR(20) NOT NULL, status_comment TEXT DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, project_id INT NOT NULL, submitted_by_id INT DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_C206E610166D1F9C ON client_ticket (project_id)'); + $this->addSql('CREATE INDEX IDX_C206E61079F7D87D ON client_ticket (submitted_by_id)'); + $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 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 user_allowed_projects ADD CONSTRAINT FK_B3E0FC97A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE user_allowed_projects ADD CONSTRAINT FK_B3E0FC97166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE'); + $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_book_stack_link.created_at IS \'\''); + $this->addSql('ALTER TABLE task_document ADD client_ticket_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE task_document ALTER task_id DROP NOT 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_document_owner CHECK (task_id IS NOT NULL OR client_ticket_id IS NOT NULL)'); + $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)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE client_ticket DROP CONSTRAINT FK_C206E610166D1F9C'); + $this->addSql('ALTER TABLE client_ticket DROP CONSTRAINT FK_C206E61079F7D87D'); + $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 client_ticket'); + $this->addSql('DROP TABLE user_allowed_projects'); + $this->addSql('ALTER TABLE task DROP CONSTRAINT FK_527EDB259B2097DD'); + $this->addSql('DROP INDEX IDX_527EDB259B2097DD'); + $this->addSql('ALTER TABLE task DROP client_ticket_id'); + $this->addSql('COMMENT ON COLUMN task_book_stack_link.created_at IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE task_document DROP CONSTRAINT FK_98A9603A9B2097DD'); + $this->addSql('DROP INDEX IDX_98A9603A9B2097DD'); + $this->addSql('ALTER TABLE task_document DROP CONSTRAINT chk_document_owner'); + $this->addSql('ALTER TABLE task_document DROP client_ticket_id'); + $this->addSql('ALTER TABLE task_document ALTER task_id SET NOT NULL'); + $this->addSql('ALTER TABLE "user" DROP CONSTRAINT FK_8D93D64919EB6921'); + $this->addSql('DROP INDEX IDX_8D93D64919EB6921'); + $this->addSql('ALTER TABLE "user" DROP client_id'); + } +} From d2e27a04ceb2c35944dd2aa399eb07032cca2fdd Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:25:45 +0100 Subject: [PATCH 15/89] feat : add ClientTicketRepository Co-Authored-By: Claude Sonnet 4.6 --- src/Repository/ClientTicketRepository.php | 34 +++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/Repository/ClientTicketRepository.php diff --git a/src/Repository/ClientTicketRepository.php b/src/Repository/ClientTicketRepository.php new file mode 100644 index 0000000..3f661b7 --- /dev/null +++ b/src/Repository/ClientTicketRepository.php @@ -0,0 +1,34 @@ + + */ +class ClientTicketRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ClientTicket::class); + } + + public function findNextNumberForProject(Project $project): int + { + $result = $this->createQueryBuilder('ct') + ->select('MAX(ct.number)') + ->where('ct.project = :project') + ->setParameter('project', $project) + ->getQuery() + ->getSingleScalarResult() + ; + + return ((int) $result) + 1; + } +} From f27297517c3ec004ab9b75c11d63b8f101bf7f25 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:27:00 +0100 Subject: [PATCH 16/89] feat : add ClientTicketNumberProcessor Co-Authored-By: Claude Sonnet 4.6 --- src/State/ClientTicketNumberProcessor.php | 61 +++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/State/ClientTicketNumberProcessor.php diff --git a/src/State/ClientTicketNumberProcessor.php b/src/State/ClientTicketNumberProcessor.php new file mode 100644 index 0000000..2f1cae1 --- /dev/null +++ b/src/State/ClientTicketNumberProcessor.php @@ -0,0 +1,61 @@ + + */ +final readonly class ClientTicketNumberProcessor implements ProcessorInterface +{ + public function __construct( + private EntityManagerInterface $entityManager, + private Security $security, + private ClientTicketRepository $clientTicketRepository, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket + { + assert($data instanceof ClientTicket); + + $user = $this->security->getUser(); + assert($user instanceof User); + + if (null === $user->getClient()) { + throw new AccessDeniedHttpException('Only client users can create tickets.'); + } + + $project = $data->getProject(); + if (null === $project) { + throw new BadRequestHttpException('Project is required.'); + } + + if (!$user->getAllowedProjects()->contains($project)) { + throw new AccessDeniedHttpException('You do not have access to this project.'); + } + + $nextNumber = $this->clientTicketRepository->findNextNumberForProject($project); + $data->setNumber($nextNumber); + $data->setSubmittedBy($user); + $data->setStatus('new'); + $data->setCreatedAt(new DateTimeImmutable()); + $data->setUpdatedAt(new DateTimeImmutable()); + + $this->entityManager->persist($data); + $this->entityManager->flush(); + + return $data; + } +} From 9a9416d6c8dfd8ec32b3c4cb4c342f1d2ee5bee9 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:27:06 +0100 Subject: [PATCH 17/89] fix : apply review fixes to MCP plan and spec Fix getIsFinal() method name, enrich create/update tool return formats to match get/list consistency, fix duplicate Reference section in spec, correct tool count to 22. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-15-mcp-server.md | 106 +++++++++++++++--- 1 file changed, 91 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/plans/2026-03-15-mcp-server.md b/docs/superpowers/plans/2026-03-15-mcp-server.md index 2c65edd..050be76 100644 --- a/docs/superpowers/plans/2026-03-15-mcp-server.md +++ b/docs/superpowers/plans/2026-03-15-mcp-server.md @@ -4,7 +4,7 @@ **Goal:** Add an MCP server to Lesstime exposing projects, tasks, and time tracking for AI clients via STDIO and HTTP transports. -**Architecture:** Install `symfony/mcp-bundle`, create 21 tool classes in `src/Mcp/Tool/` organized by domain (Project, Task, TaskMeta, TimeEntry, Reference). HTTP transport secured by API token on User entity with a custom Symfony authenticator. STDIO for local Claude Code usage. +**Architecture:** Install `symfony/mcp-bundle`, create 22 tool classes in `src/Mcp/Tool/` organized by domain (Project, Task, TaskMeta, TimeEntry, Reference). HTTP transport secured by API token on User entity with a custom Symfony authenticator. STDIO for local Claude Code usage. **Tech Stack:** symfony/mcp-bundle, Symfony 8 security (custom authenticator), Doctrine ORM, PHP 8.4 @@ -931,7 +931,7 @@ class GetTaskTool 'id' => $task->getStatus()->getId(), 'label' => $task->getStatus()->getLabel(), 'color' => $task->getStatus()->getColor(), - 'isFinal' => $task->getStatus()->isFinal(), + 'isFinal' => $task->getStatus()->getIsFinal(), ] : null, 'priority' => $task->getPriority() ? [ 'id' => $task->getPriority()->getId(), @@ -1109,16 +1109,39 @@ class CreateTaskTool 'id' => $task->getId(), 'number' => $task->getNumber(), 'title' => $task->getTitle(), + 'description' => $task->getDescription(), + 'status' => $task->getStatus() ? [ + 'id' => $task->getStatus()->getId(), + 'label' => $task->getStatus()->getLabel(), + 'color' => $task->getStatus()->getColor(), + ] : null, + 'priority' => $task->getPriority() ? [ + 'id' => $task->getPriority()->getId(), + 'label' => $task->getPriority()->getLabel(), + 'color' => $task->getPriority()->getColor(), + ] : null, + 'effort' => $task->getEffort() ? [ + 'id' => $task->getEffort()->getId(), + 'label' => $task->getEffort()->getLabel(), + ] : null, + 'assignee' => $task->getAssignee() ? [ + 'id' => $task->getAssignee()->getId(), + 'username' => $task->getAssignee()->getUsername(), + ] : null, + 'group' => $task->getGroup() ? [ + 'id' => $task->getGroup()->getId(), + 'title' => $task->getGroup()->getTitle(), + ] : null, 'project' => [ 'id' => $project->getId(), 'code' => $project->getCode(), 'name' => $project->getName(), ], - 'status' => $task->getStatus() ? ['id' => $task->getStatus()->getId(), 'label' => $task->getStatus()->getLabel()] : null, - 'priority' => $task->getPriority() ? ['id' => $task->getPriority()->getId(), 'label' => $task->getPriority()->getLabel()] : null, - 'effort' => $task->getEffort() ? ['id' => $task->getEffort()->getId(), 'label' => $task->getEffort()->getLabel()] : null, - 'assignee' => $task->getAssignee() ? ['id' => $task->getAssignee()->getId(), 'username' => $task->getAssignee()->getUsername()] : null, - 'group' => $task->getGroup() ? ['id' => $task->getGroup()->getId(), 'title' => $task->getGroup()->getTitle()] : null, + 'tags' => $task->getTags()->map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), + 'archived' => $task->isArchived(), ]); } } @@ -1242,13 +1265,38 @@ class UpdateTaskTool 'id' => $task->getId(), 'number' => $task->getNumber(), 'title' => $task->getTitle(), + 'description' => $task->getDescription(), + 'status' => $task->getStatus() ? [ + 'id' => $task->getStatus()->getId(), + 'label' => $task->getStatus()->getLabel(), + 'color' => $task->getStatus()->getColor(), + ] : null, + 'priority' => $task->getPriority() ? [ + 'id' => $task->getPriority()->getId(), + 'label' => $task->getPriority()->getLabel(), + 'color' => $task->getPriority()->getColor(), + ] : null, + 'effort' => $task->getEffort() ? [ + 'id' => $task->getEffort()->getId(), + 'label' => $task->getEffort()->getLabel(), + ] : null, + 'assignee' => $task->getAssignee() ? [ + 'id' => $task->getAssignee()->getId(), + 'username' => $task->getAssignee()->getUsername(), + ] : null, + 'group' => $task->getGroup() ? [ + 'id' => $task->getGroup()->getId(), + 'title' => $task->getGroup()->getTitle(), + ] : null, 'project' => [ 'id' => $task->getProject()->getId(), 'code' => $task->getProject()->getCode(), + 'name' => $task->getProject()->getName(), ], - 'status' => $task->getStatus() ? ['id' => $task->getStatus()->getId(), 'label' => $task->getStatus()->getLabel()] : null, - 'priority' => $task->getPriority() ? ['id' => $task->getPriority()->getId(), 'label' => $task->getPriority()->getLabel()] : null, - 'assignee' => $task->getAssignee() ? ['id' => $task->getAssignee()->getId(), 'username' => $task->getAssignee()->getUsername()] : null, + 'tags' => $task->getTags()->map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), 'archived' => $task->isArchived(), ]); } @@ -1352,7 +1400,7 @@ class ListStatusesTool 'label' => $s->getLabel(), 'color' => $s->getColor(), 'position' => $s->getPosition(), - 'isFinal' => $s->isFinal(), + 'isFinal' => $s->getIsFinal(), ], $statuses)); } } @@ -1851,11 +1899,27 @@ class CreateTimeEntryTool return json_encode([ 'id' => $entry->getId(), 'title' => $entry->getTitle(), + 'description' => $entry->getDescription(), 'startedAt' => $entry->getStartedAt()?->format('c'), 'stoppedAt' => $entry->getStoppedAt()?->format('c'), + 'duration' => $entry->getStoppedAt() && $entry->getStartedAt() + ? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60) + : null, 'user' => ['id' => $user->getId(), 'username' => $user->getUsername()], - 'project' => $entry->getProject() ? ['id' => $entry->getProject()->getId(), 'code' => $entry->getProject()->getCode()] : null, - 'task' => $entry->getTask() ? ['id' => $entry->getTask()->getId(), 'number' => $entry->getTask()->getNumber()] : null, + 'project' => $entry->getProject() ? [ + 'id' => $entry->getProject()->getId(), + 'code' => $entry->getProject()->getCode(), + 'name' => $entry->getProject()->getName(), + ] : null, + 'task' => $entry->getTask() ? [ + 'id' => $entry->getTask()->getId(), + 'number' => $entry->getTask()->getNumber(), + 'title' => $entry->getTask()->getTitle(), + ] : null, + 'tags' => $entry->getTags()->map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), ]); } } @@ -1957,8 +2021,20 @@ class UpdateTimeEntryTool ? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60) : null, 'user' => ['id' => $entry->getUser()->getId(), 'username' => $entry->getUser()->getUsername()], - 'project' => $entry->getProject() ? ['id' => $entry->getProject()->getId(), 'code' => $entry->getProject()->getCode()] : null, - 'task' => $entry->getTask() ? ['id' => $entry->getTask()->getId(), 'number' => $entry->getTask()->getNumber()] : null, + 'project' => $entry->getProject() ? [ + 'id' => $entry->getProject()->getId(), + 'code' => $entry->getProject()->getCode(), + 'name' => $entry->getProject()->getName(), + ] : null, + 'task' => $entry->getTask() ? [ + 'id' => $entry->getTask()->getId(), + 'number' => $entry->getTask()->getNumber(), + 'title' => $entry->getTask()->getTitle(), + ] : null, + 'tags' => $entry->getTags()->map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), ]); } } From f33f2f95ec90df942bd487047a8f292ea477e1de Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:27:10 +0100 Subject: [PATCH 18/89] feat : add ClientTicketStatusProcessor Co-Authored-By: Claude Sonnet 4.6 --- src/State/ClientTicketStatusProcessor.php | 56 +++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/State/ClientTicketStatusProcessor.php diff --git a/src/State/ClientTicketStatusProcessor.php b/src/State/ClientTicketStatusProcessor.php new file mode 100644 index 0000000..271834a --- /dev/null +++ b/src/State/ClientTicketStatusProcessor.php @@ -0,0 +1,56 @@ + + */ +final readonly class ClientTicketStatusProcessor implements ProcessorInterface +{ + private const FORBIDDEN_TRANSITIONS = [ + 'done' => ['new'], + 'rejected' => ['new'], + ]; + + public function __construct( + private EntityManagerInterface $entityManager, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket + { + assert($data instanceof ClientTicket); + + $originalData = $context['previous_data'] ?? null; + if ($originalData instanceof ClientTicket) { + $oldStatus = $originalData->getStatus(); + $newStatus = $data->getStatus(); + + if ($oldStatus !== $newStatus) { + $forbidden = self::FORBIDDEN_TRANSITIONS[$oldStatus] ?? []; + if (in_array($newStatus, $forbidden, true)) { + throw new BadRequestHttpException(sprintf('Transition from "%s" to "%s" is not allowed.', $oldStatus, $newStatus)); + } + + if ('rejected' === $newStatus && (null === $data->getStatusComment() || '' === trim($data->getStatusComment()))) { + throw new BadRequestHttpException('A comment is required when rejecting a ticket.'); + } + } + } + + $data->setUpdatedAt(new DateTimeImmutable()); + + $this->entityManager->persist($data); + $this->entityManager->flush(); + + return $data; + } +} From b6cfe9d7d4ae8bf346d544d6a04dba6db0d63026 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:27:24 +0100 Subject: [PATCH 19/89] feat : add ClientTicketProvider with filtering Co-Authored-By: Claude Sonnet 4.6 --- src/State/ClientTicketProvider.php | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/State/ClientTicketProvider.php diff --git a/src/State/ClientTicketProvider.php b/src/State/ClientTicketProvider.php new file mode 100644 index 0000000..ee1dcbd --- /dev/null +++ b/src/State/ClientTicketProvider.php @@ -0,0 +1,70 @@ + + */ +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 User); + + $repo = $this->entityManager->getRepository(ClientTicket::class); + + // Single item + if (isset($uriVariables['id'])) { + $ticket = $repo->find($uriVariables['id']); + if (null === $ticket) { + return null; + } + if (!$this->security->isGranted('ROLE_ADMIN') && $ticket->getSubmittedBy() !== $user) { + return null; + } + + return $ticket; + } + + // Collection with manual filtering + $qb = $repo->createQueryBuilder('ct') + ->orderBy('ct.createdAt', 'DESC') + ; + + // ROLE_CLIENT: only own tickets + if (!$this->security->isGranted('ROLE_ADMIN')) { + $qb->andWhere('ct.submittedBy = :user')->setParameter('user', $user); + } + + // Apply filters from query parameters + $filters = $context['filters'] ?? []; + if (isset($filters['project'])) { + $projectId = is_numeric($filters['project']) ? (int) $filters['project'] : (int) basename($filters['project']); + $qb->andWhere('ct.project = :project')->setParameter('project', $projectId); + } + if (isset($filters['status'])) { + $qb->andWhere('ct.status = :status')->setParameter('status', $filters['status']); + } + if (isset($filters['submittedBy']) && $this->security->isGranted('ROLE_ADMIN')) { + $submittedById = is_numeric($filters['submittedBy']) ? (int) $filters['submittedBy'] : (int) basename($filters['submittedBy']); + $qb->andWhere('ct.submittedBy = :submittedBy')->setParameter('submittedBy', $submittedById); + } + + return $qb->getQuery()->getResult(); + } +} From 851953df1e746a4a412dbea76ff427c41a1ca811 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:28:04 +0100 Subject: [PATCH 20/89] feat : generalize TaskDocumentProcessor for client tickets Co-Authored-By: Claude Sonnet 4.6 --- src/Entity/TaskDocument.php | 2 +- src/State/TaskDocumentProcessor.php | 42 +++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/Entity/TaskDocument.php b/src/Entity/TaskDocument.php index df03cbd..b11364a 100644 --- a/src/Entity/TaskDocument.php +++ b/src/Entity/TaskDocument.php @@ -22,7 +22,7 @@ use Symfony\Component\Serializer\Attribute\Groups; new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"), new Get(security: "is_granted('ROLE_USER')"), new Post( - security: "is_granted('ROLE_ADMIN')", + security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')", processor: TaskDocumentProcessor::class, deserialize: false, ), diff --git a/src/State/TaskDocumentProcessor.php b/src/State/TaskDocumentProcessor.php index 3ed8ed6..aa98f63 100644 --- a/src/State/TaskDocumentProcessor.php +++ b/src/State/TaskDocumentProcessor.php @@ -6,12 +6,14 @@ namespace App\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; +use App\Entity\ClientTicket; use App\Entity\Task; use App\Entity\TaskDocument; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Uid\Uuid; @@ -50,18 +52,41 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface throw new BadRequestHttpException('File size exceeds 50 MB limit.'); } - $taskIri = $request->request->get('task'); + $taskIri = $request->request->get('task'); + $clientTicketIri = $request->request->get('clientTicket'); - if (null === $taskIri || '' === $taskIri) { - throw new BadRequestHttpException('Task IRI is required.'); + if ((null === $taskIri || '' === $taskIri) && (null === $clientTicketIri || '' === $clientTicketIri)) { + throw new BadRequestHttpException('Either task or clientTicket IRI is required.'); } - // Extract task ID from IRI (e.g., "/api/tasks/42" -> 42) - $taskId = (int) basename((string) $taskIri); - $task = $this->entityManager->getRepository(Task::class)->find($taskId); + $task = null; + $clientTicket = null; - if (null === $task) { - throw new BadRequestHttpException('Task not found.'); + if (null !== $taskIri && '' !== $taskIri) { + // Extract task ID from IRI (e.g., "/api/tasks/42" -> 42) + $taskId = (int) basename((string) $taskIri); + $task = $this->entityManager->getRepository(Task::class)->find($taskId); + + if (null === $task) { + throw new BadRequestHttpException('Task not found.'); + } + } + + if (null !== $clientTicketIri && '' !== $clientTicketIri) { + $clientTicketId = (int) basename((string) $clientTicketIri); + $clientTicket = $this->entityManager->getRepository(ClientTicket::class)->find($clientTicketId); + + if (null === $clientTicket) { + throw new BadRequestHttpException('Client ticket not found.'); + } + + // Ownership validation for ROLE_CLIENT + if (!$this->security->isGranted('ROLE_ADMIN')) { + $currentUser = $this->security->getUser(); + if ($clientTicket->getSubmittedBy() !== $currentUser) { + throw new AccessDeniedHttpException('You can only upload documents to your own tickets.'); + } + } } // Capture file metadata BEFORE move() — move invalidates the temp file @@ -80,6 +105,7 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface $document = new TaskDocument(); $document->setTask($task); + $document->setClientTicket($clientTicket); $document->setOriginalName($originalName); $document->setFileName($fileName); $document->setMimeType($mimeType); From 7f2371e522e0b4e77aa3a96ab700bb0f7f5de252 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:29:36 +0100 Subject: [PATCH 21/89] feat(frontend) : update UserData DTO for client users Co-Authored-By: Claude Sonnet 4.6 --- frontend/services/dto/user-data.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/services/dto/user-data.ts b/frontend/services/dto/user-data.ts index 3ea999e..0f618a8 100644 --- a/frontend/services/dto/user-data.ts +++ b/frontend/services/dto/user-data.ts @@ -3,10 +3,14 @@ export type UserData = { '@id'?: string username: string roles: string[] + client?: { '@id'?: string; id: number; name: string } | null + allowedProjects?: { '@id'?: string; id: number; name: string }[] } export type UserWrite = { username: string password?: string roles: string[] + client?: string | null + allowedProjects?: string[] } From 64961631e46ec12889b5ae7a62bc422252bf75e5 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:30:10 +0100 Subject: [PATCH 22/89] feat(frontend) : add client user management to admin Co-Authored-By: Claude Sonnet 4.6 --- frontend/components/user/UserDrawer.vue | 79 ++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/frontend/components/user/UserDrawer.vue b/frontend/components/user/UserDrawer.vue index 3561701..1ffcc90 100644 --- a/frontend/components/user/UserDrawer.vue +++ b/frontend/components/user/UserDrawer.vue @@ -36,6 +36,39 @@
+
+ +
+ +
+ +
+ + + Aucun projet pour ce client. + +
+
+
+ + +
+ + + {{ $t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') }) }} + + + {{ $t(`clientTicket.status.${task.clientTicket.status}`) }} + +
@@ -388,6 +405,16 @@ const { t } = useI18n() const authStore = useAuthStore() const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false) +function ticketStatusClass(status: string): string { + switch (status) { + case 'new': return 'bg-blue-100 text-blue-700' + case 'in_progress': return 'bg-yellow-100 text-yellow-700' + case 'done': return 'bg-green-100 text-green-700' + case 'rejected': return 'bg-red-100 text-red-700' + default: return 'bg-neutral-100 text-neutral-700' + } +} + const localDocuments = ref([]) const documents = computed(() => localDocuments.value) const previewDoc = ref(null) diff --git a/frontend/pages/my-tasks.vue b/frontend/pages/my-tasks.vue index e24de7f..9eafed6 100644 --- a/frontend/pages/my-tasks.vue +++ b/frontend/pages/my-tasks.vue @@ -411,12 +411,20 @@ onMounted(() => { > - - {{ task.project.code }}-{{ task.number }} - +
+ + + {{ task.project.code }}-{{ task.number }} + +

Date: Sun, 15 Mar 2026 19:42:49 +0100 Subject: [PATCH 31/89] feat(admin) : add client tickets tab with list, filters, status change, and delete Co-Authored-By: Claude Sonnet 4.6 --- .../components/admin/AdminClientTicketTab.vue | 379 ++++++++++++++++++ frontend/pages/admin.vue | 2 + 2 files changed, 381 insertions(+) create mode 100644 frontend/components/admin/AdminClientTicketTab.vue diff --git a/frontend/components/admin/AdminClientTicketTab.vue b/frontend/components/admin/AdminClientTicketTab.vue new file mode 100644 index 0000000..940f490 --- /dev/null +++ b/frontend/components/admin/AdminClientTicketTab.vue @@ -0,0 +1,379 @@ + + + + + diff --git a/frontend/pages/admin.vue b/frontend/pages/admin.vue index 1b33016..fdc9854 100644 --- a/frontend/pages/admin.vue +++ b/frontend/pages/admin.vue @@ -27,6 +27,7 @@ + @@ -43,6 +44,7 @@ const tabs = [ { key: 'priorities', label: 'Priorités' }, { key: 'tags', label: 'Tags' }, { key: 'users', label: 'Utilisateurs' }, + { key: 'client-tickets', label: 'Tickets client' }, { key: 'gitea', label: 'Gitea' }, { key: 'bookstack', label: 'BookStack' }, ] as const From 68dd9599a94f2ed282bcdf36454565482159ab06 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:42:53 +0100 Subject: [PATCH 32/89] feat(auth) : redirect client users to /portal after login and extract ticket helpers composable Co-Authored-By: Claude Sonnet 4.6 --- .../composables/useClientTicketHelpers.ts | 29 +++++ frontend/pages/login.vue | 3 +- frontend/pages/portal/projects/[id]/index.vue | 121 ++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 frontend/composables/useClientTicketHelpers.ts create mode 100644 frontend/pages/portal/projects/[id]/index.vue diff --git a/frontend/composables/useClientTicketHelpers.ts b/frontend/composables/useClientTicketHelpers.ts new file mode 100644 index 0000000..2742eef --- /dev/null +++ b/frontend/composables/useClientTicketHelpers.ts @@ -0,0 +1,29 @@ +export function useClientTicketHelpers() { + function typeBadgeClass(type: string): string { + switch (type) { + case 'bug': return 'bg-red-500' + case 'improvement': return 'bg-blue-500' + default: return 'bg-neutral-500' + } + } + + function statusBadgeClass(status: string): string { + switch (status) { + case 'new': return 'bg-blue-100 text-blue-700' + case 'in_progress': return 'bg-yellow-100 text-yellow-700' + case 'done': return 'bg-green-100 text-green-700' + case 'rejected': return 'bg-red-100 text-red-700' + default: return 'bg-neutral-100 text-neutral-700' + } + } + + function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) + } + + return { typeBadgeClass, statusBadgeClass, formatDate } +} diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue index a87da44..41fb87c 100644 --- a/frontend/pages/login.vue +++ b/frontend/pages/login.vue @@ -63,7 +63,8 @@ const handleSubmit = async () => { try { await auth.login(username.value, password.value) - await router.push('/') + const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false + await router.push(isClient ? '/portal' : '/') } finally { isSubmitting.value = false } diff --git a/frontend/pages/portal/projects/[id]/index.vue b/frontend/pages/portal/projects/[id]/index.vue new file mode 100644 index 0000000..54bd725 --- /dev/null +++ b/frontend/pages/portal/projects/[id]/index.vue @@ -0,0 +1,121 @@ + + + From 3d1a510d82c22ae77606d3e057e6b38503cf57b9 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:45:39 +0100 Subject: [PATCH 33/89] feat : add 22 MCP tool classes for projects, tasks, and time tracking Tools: list-users, list-clients, list/get/create/update-project, list/get/create/update/delete-task, list-statuses/priorities/efforts/tags, list/create/update-group, list/create/update/delete-time-entry. Attribute moved to class level for SDK discovery compatibility. Install nyholm/psr7 for HTTP transport PSR-17 support. Co-Authored-By: Claude Opus 4.6 (1M context) --- composer.json | 1 + composer.lock | 80 +++++++++- src/Mcp/Tool/Project/CreateProjectTool.php | 64 ++++++++ src/Mcp/Tool/Project/GetProjectTool.php | 63 ++++++++ src/Mcp/Tool/Project/ListProjectsTool.php | 34 ++++ src/Mcp/Tool/Project/UpdateProjectTool.php | 77 +++++++++ src/Mcp/Tool/Reference/ListClientsTool.php | 27 ++++ src/Mcp/Tool/Reference/ListUsersTool.php | 26 +++ src/Mcp/Tool/Task/CreateTaskTool.php | 148 +++++++++++++++++ src/Mcp/Tool/Task/DeleteTaskTool.php | 39 +++++ src/Mcp/Tool/Task/GetTaskTool.php | 81 ++++++++++ src/Mcp/Tool/Task/ListTasksTool.php | 107 +++++++++++++ src/Mcp/Tool/Task/UpdateTaskTool.php | 151 ++++++++++++++++++ src/Mcp/Tool/TaskMeta/CreateGroupTool.php | 61 +++++++ src/Mcp/Tool/TaskMeta/ListEffortsTool.php | 26 +++ src/Mcp/Tool/TaskMeta/ListGroupsTool.php | 39 +++++ src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php | 27 ++++ src/Mcp/Tool/TaskMeta/ListStatusesTool.php | 29 ++++ src/Mcp/Tool/TaskMeta/ListTagsTool.php | 27 ++++ src/Mcp/Tool/TaskMeta/UpdateGroupTool.php | 63 ++++++++ .../Tool/TimeEntry/CreateTimeEntryTool.php | 121 ++++++++++++++ .../Tool/TimeEntry/DeleteTimeEntryTool.php | 38 +++++ .../Tool/TimeEntry/ListTimeEntriesTool.php | 88 ++++++++++ .../Tool/TimeEntry/UpdateTimeEntryTool.php | 111 +++++++++++++ 24 files changed, 1527 insertions(+), 1 deletion(-) create mode 100644 src/Mcp/Tool/Project/CreateProjectTool.php create mode 100644 src/Mcp/Tool/Project/GetProjectTool.php create mode 100644 src/Mcp/Tool/Project/ListProjectsTool.php create mode 100644 src/Mcp/Tool/Project/UpdateProjectTool.php create mode 100644 src/Mcp/Tool/Reference/ListClientsTool.php create mode 100644 src/Mcp/Tool/Reference/ListUsersTool.php create mode 100644 src/Mcp/Tool/Task/CreateTaskTool.php create mode 100644 src/Mcp/Tool/Task/DeleteTaskTool.php create mode 100644 src/Mcp/Tool/Task/GetTaskTool.php create mode 100644 src/Mcp/Tool/Task/ListTasksTool.php create mode 100644 src/Mcp/Tool/Task/UpdateTaskTool.php create mode 100644 src/Mcp/Tool/TaskMeta/CreateGroupTool.php create mode 100644 src/Mcp/Tool/TaskMeta/ListEffortsTool.php create mode 100644 src/Mcp/Tool/TaskMeta/ListGroupsTool.php create mode 100644 src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php create mode 100644 src/Mcp/Tool/TaskMeta/ListStatusesTool.php create mode 100644 src/Mcp/Tool/TaskMeta/ListTagsTool.php create mode 100644 src/Mcp/Tool/TaskMeta/UpdateGroupTool.php create mode 100644 src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php create mode 100644 src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php create mode 100644 src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php create mode 100644 src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php diff --git a/composer.json b/composer.json index 460ec59..c8a5a8a 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "doctrine/orm": "^3.6", "lexik/jwt-authentication-bundle": "^3.2", "nelmio/cors-bundle": "^2.6", + "nyholm/psr7": "^1.8", "phpdocumentor/reflection-docblock": "^5.6|^6.0", "phpstan/phpdoc-parser": "^2.3", "symfony/asset": "8.0.*", diff --git a/composer.lock b/composer.lock index 92ed6dd..be8f461 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "75456bd21a6cc5bf33989f885a1ab515", + "content-hash": "75b9dbecf38167d0554dfd64a986a40e", "packages": [ { "name": "api-platform/doctrine-common", @@ -2690,6 +2690,84 @@ }, "time": "2026-01-12T15:59:08+00:00" }, + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, { "name": "opis/json-schema", "version": "2.6.0", diff --git a/src/Mcp/Tool/Project/CreateProjectTool.php b/src/Mcp/Tool/Project/CreateProjectTool.php new file mode 100644 index 0000000..75001c4 --- /dev/null +++ b/src/Mcp/Tool/Project/CreateProjectTool.php @@ -0,0 +1,64 @@ +setName($name); + $project->setCode($code); + + if (null !== $description) { + $project->setDescription($description); + } + if (null !== $color) { + $project->setColor($color); + } + if (null !== $clientId) { + $client = $this->clientRepository->find($clientId); + if (null === $client) { + throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId)); + } + $project->setClient($client); + } + + $this->entityManager->persist($project); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + ]); + } +} diff --git a/src/Mcp/Tool/Project/GetProjectTool.php b/src/Mcp/Tool/Project/GetProjectTool.php new file mode 100644 index 0000000..c886efc --- /dev/null +++ b/src/Mcp/Tool/Project/GetProjectTool.php @@ -0,0 +1,63 @@ +projectRepository->find($id); + + if (null === $project) { + throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $id)); + } + + // Count tasks per status + $qb = $this->taskRepository->createQueryBuilder('t') + ->select('s.label AS statusLabel, COUNT(t.id) AS taskCount') + ->leftJoin('t.status', 's') + ->where('t.project = :project') + ->setParameter('project', $project) + ->groupBy('s.id, s.label') + ; + + $statusCounts = []; + $totalTasks = 0; + foreach ($qb->getQuery()->getResult() as $row) { + $label = $row['statusLabel'] ?? 'No status'; + $count = (int) $row['taskCount']; + $statusCounts[$label] = $count; + $totalTasks += $count; + } + + return json_encode([ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + 'taskSummary' => $statusCounts, + 'totalTasks' => $totalTasks, + ]); + } +} diff --git a/src/Mcp/Tool/Project/ListProjectsTool.php b/src/Mcp/Tool/Project/ListProjectsTool.php new file mode 100644 index 0000000..eae89ca --- /dev/null +++ b/src/Mcp/Tool/Project/ListProjectsTool.php @@ -0,0 +1,34 @@ +projectRepository->findBy(['archived' => $archived], ['name' => 'ASC']); + + return json_encode(array_map(fn ($project) => [ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + ], $projects)); + } +} diff --git a/src/Mcp/Tool/Project/UpdateProjectTool.php b/src/Mcp/Tool/Project/UpdateProjectTool.php new file mode 100644 index 0000000..ee08b96 --- /dev/null +++ b/src/Mcp/Tool/Project/UpdateProjectTool.php @@ -0,0 +1,77 @@ +projectRepository->find($id); + + if (null === $project) { + throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $id)); + } + + if (null !== $name) { + $project->setName($name); + } + if (null !== $code) { + $project->setCode($code); + } + if (null !== $description) { + $project->setDescription($description); + } + if (null !== $color) { + $project->setColor($color); + } + if (null !== $clientId) { + $client = $this->clientRepository->find($clientId); + if (null === $client) { + throw new InvalidArgumentException(sprintf('Client with ID %d not found.', $clientId)); + } + $project->setClient($client); + } + if (null !== $archived) { + $project->setArchived($archived); + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + ]); + } +} diff --git a/src/Mcp/Tool/Reference/ListClientsTool.php b/src/Mcp/Tool/Reference/ListClientsTool.php new file mode 100644 index 0000000..8c29a65 --- /dev/null +++ b/src/Mcp/Tool/Reference/ListClientsTool.php @@ -0,0 +1,27 @@ +clientRepository->findBy([], ['name' => 'ASC']); + + return json_encode(array_map(fn ($client) => [ + 'id' => $client->getId(), + 'name' => $client->getName(), + 'email' => $client->getEmail(), + ], $clients)); + } +} diff --git a/src/Mcp/Tool/Reference/ListUsersTool.php b/src/Mcp/Tool/Reference/ListUsersTool.php new file mode 100644 index 0000000..0115935 --- /dev/null +++ b/src/Mcp/Tool/Reference/ListUsersTool.php @@ -0,0 +1,26 @@ +userRepository->findBy([], ['username' => 'ASC']); + + return json_encode(array_map(fn ($user) => [ + 'id' => $user->getId(), + 'username' => $user->getUsername(), + ], $users)); + } +} diff --git a/src/Mcp/Tool/Task/CreateTaskTool.php b/src/Mcp/Tool/Task/CreateTaskTool.php new file mode 100644 index 0000000..11093cb --- /dev/null +++ b/src/Mcp/Tool/Task/CreateTaskTool.php @@ -0,0 +1,148 @@ +projectRepository->find($projectId); + if (null === $project) { + throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId)); + } + + $task = new Task(); + $task->setProject($project); + $task->setTitle($title); + $task->setNumber($this->taskRepository->findMaxNumberByProject($project) + 1); + + if (null !== $description) { + $task->setDescription($description); + } + if (null !== $statusId) { + $status = $this->taskStatusRepository->find($statusId); + if (null === $status) { + throw new InvalidArgumentException(sprintf('TaskStatus with ID %d not found.', $statusId)); + } + $task->setStatus($status); + } + if (null !== $priorityId) { + $priority = $this->taskPriorityRepository->find($priorityId); + if (null === $priority) { + throw new InvalidArgumentException(sprintf('TaskPriority with ID %d not found.', $priorityId)); + } + $task->setPriority($priority); + } + if (null !== $effortId) { + $effort = $this->taskEffortRepository->find($effortId); + if (null === $effort) { + throw new InvalidArgumentException(sprintf('TaskEffort with ID %d not found.', $effortId)); + } + $task->setEffort($effort); + } + if (null !== $assigneeId) { + $assignee = $this->userRepository->find($assigneeId); + if (null === $assignee) { + throw new InvalidArgumentException(sprintf('User with ID %d not found.', $assigneeId)); + } + $task->setAssignee($assignee); + } + if (null !== $groupId) { + $group = $this->taskGroupRepository->find($groupId); + if (null === $group) { + throw new InvalidArgumentException(sprintf('TaskGroup with ID %d not found.', $groupId)); + } + $task->setGroup($group); + } + if (null !== $tagIds) { + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $tagId)); + } + $task->addTag($tag); + } + } + + $this->entityManager->persist($task); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'description' => $task->getDescription(), + 'status' => $task->getStatus() ? [ + 'id' => $task->getStatus()->getId(), + 'label' => $task->getStatus()->getLabel(), + 'color' => $task->getStatus()->getColor(), + ] : null, + 'priority' => $task->getPriority() ? [ + 'id' => $task->getPriority()->getId(), + 'label' => $task->getPriority()->getLabel(), + 'color' => $task->getPriority()->getColor(), + ] : null, + 'effort' => $task->getEffort() ? [ + 'id' => $task->getEffort()->getId(), + 'label' => $task->getEffort()->getLabel(), + ] : null, + 'assignee' => $task->getAssignee() ? [ + 'id' => $task->getAssignee()->getId(), + 'username' => $task->getAssignee()->getUsername(), + ] : null, + 'group' => $task->getGroup() ? [ + 'id' => $task->getGroup()->getId(), + 'title' => $task->getGroup()->getTitle(), + ] : null, + 'project' => [ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + ], + 'tags' => $task->getTags()->map(fn ($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), + 'archived' => $task->isArchived(), + ]); + } +} diff --git a/src/Mcp/Tool/Task/DeleteTaskTool.php b/src/Mcp/Tool/Task/DeleteTaskTool.php new file mode 100644 index 0000000..788d972 --- /dev/null +++ b/src/Mcp/Tool/Task/DeleteTaskTool.php @@ -0,0 +1,39 @@ +taskRepository->find($id); + + if (null === $task) { + throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $id)); + } + + $taskCode = $task->getProject()->getCode().'-'.$task->getNumber(); + $this->entityManager->remove($task); + $this->entityManager->flush(); + + return json_encode([ + 'success' => true, + 'message' => sprintf('Task %s deleted.', $taskCode), + ]); + } +} diff --git a/src/Mcp/Tool/Task/GetTaskTool.php b/src/Mcp/Tool/Task/GetTaskTool.php new file mode 100644 index 0000000..f214f3f --- /dev/null +++ b/src/Mcp/Tool/Task/GetTaskTool.php @@ -0,0 +1,81 @@ +taskRepository->find($id); + + if (null === $task) { + throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $id)); + } + + return json_encode([ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'description' => $task->getDescription(), + 'status' => $task->getStatus() ? [ + 'id' => $task->getStatus()->getId(), + 'label' => $task->getStatus()->getLabel(), + 'color' => $task->getStatus()->getColor(), + 'isFinal' => $task->getStatus()->getIsFinal(), + ] : null, + 'priority' => $task->getPriority() ? [ + 'id' => $task->getPriority()->getId(), + 'label' => $task->getPriority()->getLabel(), + 'color' => $task->getPriority()->getColor(), + ] : null, + 'effort' => $task->getEffort() ? [ + 'id' => $task->getEffort()->getId(), + 'label' => $task->getEffort()->getLabel(), + ] : null, + 'assignee' => $task->getAssignee() ? [ + 'id' => $task->getAssignee()->getId(), + 'username' => $task->getAssignee()->getUsername(), + ] : null, + 'group' => $task->getGroup() ? [ + 'id' => $task->getGroup()->getId(), + 'title' => $task->getGroup()->getTitle(), + 'color' => $task->getGroup()->getColor(), + ] : null, + 'project' => [ + 'id' => $task->getProject()->getId(), + 'code' => $task->getProject()->getCode(), + 'name' => $task->getProject()->getName(), + ], + 'tags' => $task->getTags()->map(fn ($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + 'color' => $t->getColor(), + ])->toArray(), + 'documents' => $task->getDocuments()->map(fn ($doc) => [ + 'id' => $doc->getId(), + 'originalName' => $doc->getOriginalName(), + 'mimeType' => $doc->getMimeType(), + 'size' => $doc->getSize(), + 'createdAt' => $doc->getCreatedAt()?->format('c'), + 'uploadedBy' => $doc->getUploadedBy() ? [ + 'id' => $doc->getUploadedBy()->getId(), + 'username' => $doc->getUploadedBy()->getUsername(), + ] : null, + ])->toArray(), + 'archived' => $task->isArchived(), + ]); + } +} diff --git a/src/Mcp/Tool/Task/ListTasksTool.php b/src/Mcp/Tool/Task/ListTasksTool.php new file mode 100644 index 0000000..bc914d6 --- /dev/null +++ b/src/Mcp/Tool/Task/ListTasksTool.php @@ -0,0 +1,107 @@ +taskRepository->createQueryBuilder('t') + ->leftJoin('t.status', 's')->addSelect('s') + ->leftJoin('t.priority', 'p')->addSelect('p') + ->leftJoin('t.assignee', 'a')->addSelect('a') + ->leftJoin('t.project', 'pr')->addSelect('pr') + ->leftJoin('t.effort', 'e')->addSelect('e') + ->leftJoin('t.group', 'g')->addSelect('g') + ->leftJoin('t.tags', 'tg')->addSelect('tg') + ->where('t.archived = :archived') + ->setParameter('archived', $archived) + ->orderBy('t.id', 'DESC') + ->setMaxResults($limit) + ; + + if (null !== $projectId) { + $qb->andWhere('pr.id = :projectId')->setParameter('projectId', $projectId); + } + if (null !== $statusId) { + $qb->andWhere('s.id = :statusId')->setParameter('statusId', $statusId); + } + if (null !== $assigneeId) { + $qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId); + } + if (null !== $priorityId) { + $qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId); + } + if (null !== $groupId) { + $qb->andWhere('t.group = :groupId')->setParameter('groupId', $groupId); + } + + $tasks = $qb->getQuery()->getResult(); + + if (null !== $tagIds) { + $tasks = array_filter($tasks, function ($task) use ($tagIds) { + $taskTagIds = $task->getTags()->map(fn ($t) => $t->getId())->toArray(); + + return !empty(array_intersect($tagIds, $taskTagIds)); + }); + } + + return json_encode(array_map(fn ($task) => [ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'status' => $task->getStatus() ? [ + 'id' => $task->getStatus()->getId(), + 'label' => $task->getStatus()->getLabel(), + 'color' => $task->getStatus()->getColor(), + ] : null, + 'priority' => $task->getPriority() ? [ + 'id' => $task->getPriority()->getId(), + 'label' => $task->getPriority()->getLabel(), + 'color' => $task->getPriority()->getColor(), + ] : null, + 'assignee' => $task->getAssignee() ? [ + 'id' => $task->getAssignee()->getId(), + 'username' => $task->getAssignee()->getUsername(), + ] : null, + 'effort' => $task->getEffort() ? [ + 'id' => $task->getEffort()->getId(), + 'label' => $task->getEffort()->getLabel(), + ] : null, + 'group' => $task->getGroup() ? [ + 'id' => $task->getGroup()->getId(), + 'title' => $task->getGroup()->getTitle(), + ] : null, + 'project' => [ + 'id' => $task->getProject()->getId(), + 'code' => $task->getProject()->getCode(), + 'name' => $task->getProject()->getName(), + ], + 'tags' => $task->getTags()->map(fn ($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), + 'archived' => $task->isArchived(), + ], array_values($tasks))); + } +} diff --git a/src/Mcp/Tool/Task/UpdateTaskTool.php b/src/Mcp/Tool/Task/UpdateTaskTool.php new file mode 100644 index 0000000..96db18f --- /dev/null +++ b/src/Mcp/Tool/Task/UpdateTaskTool.php @@ -0,0 +1,151 @@ +taskRepository->find($id); + + if (null === $task) { + throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $id)); + } + + if (null !== $title) { + $task->setTitle($title); + } + if (null !== $description) { + $task->setDescription($description); + } + if (null !== $statusId) { + $status = $this->taskStatusRepository->find($statusId); + if (null === $status) { + throw new InvalidArgumentException(sprintf('TaskStatus with ID %d not found.', $statusId)); + } + $task->setStatus($status); + } + if (null !== $priorityId) { + $priority = $this->taskPriorityRepository->find($priorityId); + if (null === $priority) { + throw new InvalidArgumentException(sprintf('TaskPriority with ID %d not found.', $priorityId)); + } + $task->setPriority($priority); + } + if (null !== $effortId) { + $effort = $this->taskEffortRepository->find($effortId); + if (null === $effort) { + throw new InvalidArgumentException(sprintf('TaskEffort with ID %d not found.', $effortId)); + } + $task->setEffort($effort); + } + if (null !== $assigneeId) { + $assignee = $this->userRepository->find($assigneeId); + if (null === $assignee) { + throw new InvalidArgumentException(sprintf('User with ID %d not found.', $assigneeId)); + } + $task->setAssignee($assignee); + } + if (null !== $groupId) { + $group = $this->taskGroupRepository->find($groupId); + if (null === $group) { + throw new InvalidArgumentException(sprintf('TaskGroup with ID %d not found.', $groupId)); + } + $task->setGroup($group); + } + if (null !== $tagIds) { + // Clear existing tags and set new ones + foreach ($task->getTags()->toArray() as $existingTag) { + $task->removeTag($existingTag); + } + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $tagId)); + } + $task->addTag($tag); + } + } + if (null !== $archived) { + $task->setArchived($archived); + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'description' => $task->getDescription(), + 'status' => $task->getStatus() ? [ + 'id' => $task->getStatus()->getId(), + 'label' => $task->getStatus()->getLabel(), + 'color' => $task->getStatus()->getColor(), + ] : null, + 'priority' => $task->getPriority() ? [ + 'id' => $task->getPriority()->getId(), + 'label' => $task->getPriority()->getLabel(), + 'color' => $task->getPriority()->getColor(), + ] : null, + 'effort' => $task->getEffort() ? [ + 'id' => $task->getEffort()->getId(), + 'label' => $task->getEffort()->getLabel(), + ] : null, + 'assignee' => $task->getAssignee() ? [ + 'id' => $task->getAssignee()->getId(), + 'username' => $task->getAssignee()->getUsername(), + ] : null, + 'group' => $task->getGroup() ? [ + 'id' => $task->getGroup()->getId(), + 'title' => $task->getGroup()->getTitle(), + ] : null, + 'project' => [ + 'id' => $task->getProject()->getId(), + 'code' => $task->getProject()->getCode(), + 'name' => $task->getProject()->getName(), + ], + 'tags' => $task->getTags()->map(fn ($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), + 'archived' => $task->isArchived(), + ]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/CreateGroupTool.php b/src/Mcp/Tool/TaskMeta/CreateGroupTool.php new file mode 100644 index 0000000..a98a91e --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/CreateGroupTool.php @@ -0,0 +1,61 @@ +projectRepository->find($projectId); + if (null === $project) { + throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId)); + } + + $group = new TaskGroup(); + $group->setProject($project); + $group->setTitle($title); + + if (null !== $description) { + $group->setDescription($description); + } + if (null !== $color) { + $group->setColor($color); + } + + $this->entityManager->persist($group); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $group->getId(), + 'title' => $group->getTitle(), + 'description' => $group->getDescription(), + 'color' => $group->getColor(), + 'project' => [ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + ], + 'archived' => $group->isArchived(), + ]); + } +} diff --git a/src/Mcp/Tool/TaskMeta/ListEffortsTool.php b/src/Mcp/Tool/TaskMeta/ListEffortsTool.php new file mode 100644 index 0000000..1dbd4ad --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/ListEffortsTool.php @@ -0,0 +1,26 @@ +taskEffortRepository->findBy([], ['label' => 'ASC']); + + return json_encode(array_map(fn ($e) => [ + 'id' => $e->getId(), + 'label' => $e->getLabel(), + ], $efforts)); + } +} diff --git a/src/Mcp/Tool/TaskMeta/ListGroupsTool.php b/src/Mcp/Tool/TaskMeta/ListGroupsTool.php new file mode 100644 index 0000000..35cddf7 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/ListGroupsTool.php @@ -0,0 +1,39 @@ + $archived]; + if (null !== $projectId) { + $criteria['project'] = $projectId; + } + + $groups = $this->taskGroupRepository->findBy($criteria, ['title' => 'ASC']); + + return json_encode(array_map(fn ($g) => [ + 'id' => $g->getId(), + 'title' => $g->getTitle(), + 'description' => $g->getDescription(), + 'color' => $g->getColor(), + 'project' => [ + 'id' => $g->getProject()->getId(), + 'code' => $g->getProject()->getCode(), + 'name' => $g->getProject()->getName(), + ], + 'archived' => $g->isArchived(), + ], $groups)); + } +} diff --git a/src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php b/src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php new file mode 100644 index 0000000..53f3dba --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php @@ -0,0 +1,27 @@ +taskPriorityRepository->findBy([], ['label' => 'ASC']); + + return json_encode(array_map(fn ($p) => [ + 'id' => $p->getId(), + 'label' => $p->getLabel(), + 'color' => $p->getColor(), + ], $priorities)); + } +} diff --git a/src/Mcp/Tool/TaskMeta/ListStatusesTool.php b/src/Mcp/Tool/TaskMeta/ListStatusesTool.php new file mode 100644 index 0000000..9f09597 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/ListStatusesTool.php @@ -0,0 +1,29 @@ +taskStatusRepository->findBy([], ['position' => 'ASC']); + + return json_encode(array_map(fn ($s) => [ + 'id' => $s->getId(), + 'label' => $s->getLabel(), + 'color' => $s->getColor(), + 'position' => $s->getPosition(), + 'isFinal' => $s->getIsFinal(), + ], $statuses)); + } +} diff --git a/src/Mcp/Tool/TaskMeta/ListTagsTool.php b/src/Mcp/Tool/TaskMeta/ListTagsTool.php new file mode 100644 index 0000000..b91f271 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/ListTagsTool.php @@ -0,0 +1,27 @@ +taskTagRepository->findBy([], ['label' => 'ASC']); + + return json_encode(array_map(fn ($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + 'color' => $t->getColor(), + ], $tags)); + } +} diff --git a/src/Mcp/Tool/TaskMeta/UpdateGroupTool.php b/src/Mcp/Tool/TaskMeta/UpdateGroupTool.php new file mode 100644 index 0000000..3b818d8 --- /dev/null +++ b/src/Mcp/Tool/TaskMeta/UpdateGroupTool.php @@ -0,0 +1,63 @@ +taskGroupRepository->find($id); + + if (null === $group) { + throw new InvalidArgumentException(sprintf('TaskGroup with ID %d not found.', $id)); + } + + if (null !== $title) { + $group->setTitle($title); + } + if (null !== $description) { + $group->setDescription($description); + } + if (null !== $color) { + $group->setColor($color); + } + if (null !== $archived) { + $group->setArchived($archived); + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $group->getId(), + 'title' => $group->getTitle(), + 'description' => $group->getDescription(), + 'color' => $group->getColor(), + 'project' => [ + 'id' => $group->getProject()->getId(), + 'code' => $group->getProject()->getCode(), + 'name' => $group->getProject()->getName(), + ], + 'archived' => $group->isArchived(), + ]); + } +} diff --git a/src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php b/src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php new file mode 100644 index 0000000..75642c9 --- /dev/null +++ b/src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php @@ -0,0 +1,121 @@ +userRepository->find($userId); + if (null === $user) { + throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId)); + } + + // Check for existing active timer if creating a new active one + if (null === $stoppedAt) { + $activeEntry = $this->timeEntryRepository->findActiveByUser($user); + if (null !== $activeEntry) { + throw new InvalidArgumentException(sprintf('User "%s" already has an active timer (ID %d). Stop it before starting a new one.', $user->getUsername(), $activeEntry->getId())); + } + } + + $entry = new TimeEntry(); + $entry->setUser($user); + $entry->setStartedAt(new DateTimeImmutable($startedAt)); + + if (null !== $title) { + $entry->setTitle($title); + } + if (null !== $stoppedAt) { + $entry->setStoppedAt(new DateTimeImmutable($stoppedAt)); + } + if (null !== $description) { + $entry->setDescription($description); + } + if (null !== $projectId) { + $project = $this->projectRepository->find($projectId); + if (null === $project) { + throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId)); + } + $entry->setProject($project); + } + if (null !== $taskId) { + $task = $this->taskRepository->find($taskId); + if (null === $task) { + throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId)); + } + $entry->setTask($task); + } + if (null !== $tagIds) { + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $tagId)); + } + $entry->addTag($tag); + } + } + + $this->entityManager->persist($entry); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $entry->getId(), + 'title' => $entry->getTitle(), + 'description' => $entry->getDescription(), + 'startedAt' => $entry->getStartedAt()?->format('c'), + 'stoppedAt' => $entry->getStoppedAt()?->format('c'), + 'duration' => $entry->getStoppedAt() && $entry->getStartedAt() + ? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60) + : null, + 'user' => ['id' => $user->getId(), 'username' => $user->getUsername()], + 'project' => $entry->getProject() ? [ + 'id' => $entry->getProject()->getId(), + 'code' => $entry->getProject()->getCode(), + 'name' => $entry->getProject()->getName(), + ] : null, + 'task' => $entry->getTask() ? [ + 'id' => $entry->getTask()->getId(), + 'number' => $entry->getTask()->getNumber(), + 'title' => $entry->getTask()->getTitle(), + ] : null, + 'tags' => $entry->getTags()->map(fn ($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), + ]); + } +} diff --git a/src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php b/src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php new file mode 100644 index 0000000..2581b12 --- /dev/null +++ b/src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php @@ -0,0 +1,38 @@ +timeEntryRepository->find($id); + + if (null === $entry) { + throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id)); + } + + $this->entityManager->remove($entry); + $this->entityManager->flush(); + + return json_encode([ + 'success' => true, + 'message' => 'Time entry deleted.', + ]); + } +} diff --git a/src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php b/src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php new file mode 100644 index 0000000..77435c2 --- /dev/null +++ b/src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php @@ -0,0 +1,88 @@ +timeEntryRepository->createQueryBuilder('te') + ->leftJoin('te.user', 'u')->addSelect('u') + ->leftJoin('te.project', 'p')->addSelect('p') + ->leftJoin('te.task', 't')->addSelect('t') + ->leftJoin('te.tags', 'tg')->addSelect('tg') + ->orderBy('te.startedAt', 'DESC') + ->setMaxResults($limit) + ; + + if (null !== $userId) { + $qb->andWhere('u.id = :userId')->setParameter('userId', $userId); + } + if (null !== $projectId) { + $qb->andWhere('p.id = :projectId')->setParameter('projectId', $projectId); + } + if (null !== $taskId) { + $qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId); + } + if (null !== $startDate) { + $qb->andWhere('te.startedAt >= :startDate') + ->setParameter('startDate', new DateTimeImmutable($startDate.' 00:00:00')) + ; + } + if (null !== $endDate) { + $qb->andWhere('te.startedAt <= :endDate') + ->setParameter('endDate', new DateTimeImmutable($endDate.' 23:59:59')) + ; + } + + $entries = $qb->getQuery()->getResult(); + + return json_encode(array_map(fn ($entry) => [ + 'id' => $entry->getId(), + 'title' => $entry->getTitle(), + 'description' => $entry->getDescription(), + 'startedAt' => $entry->getStartedAt()?->format('c'), + 'stoppedAt' => $entry->getStoppedAt()?->format('c'), + 'duration' => $entry->getStoppedAt() && $entry->getStartedAt() + ? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60) + : null, + 'user' => [ + 'id' => $entry->getUser()->getId(), + 'username' => $entry->getUser()->getUsername(), + ], + 'project' => $entry->getProject() ? [ + 'id' => $entry->getProject()->getId(), + 'code' => $entry->getProject()->getCode(), + 'name' => $entry->getProject()->getName(), + ] : null, + 'task' => $entry->getTask() ? [ + 'id' => $entry->getTask()->getId(), + 'number' => $entry->getTask()->getNumber(), + 'title' => $entry->getTask()->getTitle(), + ] : null, + 'tags' => $entry->getTags()->map(fn ($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), + ], $entries)); + } +} diff --git a/src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php b/src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php new file mode 100644 index 0000000..2bd514d --- /dev/null +++ b/src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php @@ -0,0 +1,111 @@ +timeEntryRepository->find($id); + + if (null === $entry) { + throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id)); + } + + if (null !== $title) { + $entry->setTitle($title); + } + if (null !== $startedAt) { + $entry->setStartedAt(new DateTimeImmutable($startedAt)); + } + if (null !== $stoppedAt) { + $entry->setStoppedAt(new DateTimeImmutable($stoppedAt)); + } + if (null !== $description) { + $entry->setDescription($description); + } + if (null !== $projectId) { + $project = $this->projectRepository->find($projectId); + if (null === $project) { + throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId)); + } + $entry->setProject($project); + } + if (null !== $taskId) { + $task = $this->taskRepository->find($taskId); + if (null === $task) { + throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId)); + } + $entry->setTask($task); + } + if (null !== $tagIds) { + foreach ($entry->getTags()->toArray() as $existingTag) { + $entry->removeTag($existingTag); + } + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $tagId)); + } + $entry->addTag($tag); + } + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $entry->getId(), + 'title' => $entry->getTitle(), + 'startedAt' => $entry->getStartedAt()?->format('c'), + 'stoppedAt' => $entry->getStoppedAt()?->format('c'), + 'duration' => $entry->getStoppedAt() && $entry->getStartedAt() + ? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60) + : null, + 'user' => ['id' => $entry->getUser()->getId(), 'username' => $entry->getUser()->getUsername()], + 'project' => $entry->getProject() ? [ + 'id' => $entry->getProject()->getId(), + 'code' => $entry->getProject()->getCode(), + 'name' => $entry->getProject()->getName(), + ] : null, + 'task' => $entry->getTask() ? [ + 'id' => $entry->getTask()->getId(), + 'number' => $entry->getTask()->getNumber(), + 'title' => $entry->getTask()->getTitle(), + ] : null, + 'tags' => $entry->getTags()->map(fn ($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), + ]); + } +} From 669c36cea16be72eda2ab5015f87d1025a124514 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:45:47 +0100 Subject: [PATCH 34/89] feat(notification) : add Notification entity, repository, and migration --- migrations/Version20260315184538.php | 38 +++++ src/Entity/Notification.php | 160 ++++++++++++++++++++++ src/Repository/NotificationRepository.php | 46 +++++++ 3 files changed, 244 insertions(+) create mode 100644 migrations/Version20260315184538.php create mode 100644 src/Entity/Notification.php create mode 100644 src/Repository/NotificationRepository.php diff --git a/migrations/Version20260315184538.php b/migrations/Version20260315184538.php new file mode 100644 index 0000000..b2eda8c --- /dev/null +++ b/migrations/Version20260315184538.php @@ -0,0 +1,38 @@ +addSql('CREATE TABLE notification (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, type VARCHAR(50) NOT NULL, title VARCHAR(255) NOT NULL, message TEXT NOT NULL, is_read BOOLEAN NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_id INT NOT NULL, related_ticket_id INT DEFAULT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_BF5476CAD8C11BC9 ON notification (related_ticket_id)'); + $this->addSql('CREATE INDEX idx_notification_user ON notification (user_id)'); + $this->addSql('CREATE INDEX idx_notification_user_read ON notification (user_id, is_read)'); + $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CAA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE notification ADD CONSTRAINT FK_BF5476CAD8C11BC9 FOREIGN KEY (related_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CAA76ED395'); + $this->addSql('ALTER TABLE notification DROP CONSTRAINT FK_BF5476CAD8C11BC9'); + $this->addSql('DROP TABLE notification'); + } +} diff --git a/src/Entity/Notification.php b/src/Entity/Notification.php new file mode 100644 index 0000000..36aefb8 --- /dev/null +++ b/src/Entity/Notification.php @@ -0,0 +1,160 @@ + ['notification:read']], + denormalizationContext: ['groups' => ['notification:write']], + order: ['createdAt' => 'DESC'], +)] +#[ORM\Entity(repositoryClass: NotificationRepository::class)] +#[ORM\Index(columns: ['user_id'], name: 'idx_notification_user')] +#[ORM\Index(columns: ['user_id', 'is_read'], name: 'idx_notification_user_read')] +class Notification +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['notification:read'])] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[Groups(['notification:read'])] + private ?User $user = null; + + #[ORM\Column(length: 50)] + #[Groups(['notification:read'])] + private ?string $type = null; + + #[ORM\Column(length: 255)] + #[Groups(['notification:read'])] + private ?string $title = null; + + #[ORM\Column(type: Types::TEXT)] + #[Groups(['notification:read'])] + private ?string $message = null; + + #[ORM\ManyToOne(targetEntity: ClientTicket::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + #[Groups(['notification:read'])] + private ?ClientTicket $relatedTicket = null; + + #[ORM\Column] + #[Groups(['notification:read', 'notification:write'])] + private bool $isRead = false; + + #[ORM\Column] + #[Groups(['notification:read'])] + private ?DateTimeImmutable $createdAt = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(string $type): static + { + $this->type = $type; + + return $this; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function setMessage(string $message): static + { + $this->message = $message; + + return $this; + } + + public function getRelatedTicket(): ?ClientTicket + { + return $this->relatedTicket; + } + + public function setRelatedTicket(?ClientTicket $relatedTicket): static + { + $this->relatedTicket = $relatedTicket; + + return $this; + } + + public function isRead(): bool + { + return $this->isRead; + } + + public function setIsRead(bool $isRead): static + { + $this->isRead = $isRead; + + return $this; + } + + public function getCreatedAt(): ?DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } +} diff --git a/src/Repository/NotificationRepository.php b/src/Repository/NotificationRepository.php new file mode 100644 index 0000000..abe47fb --- /dev/null +++ b/src/Repository/NotificationRepository.php @@ -0,0 +1,46 @@ + + */ +class NotificationRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Notification::class); + } + + public function countUnreadByUser(User $user): int + { + return (int) $this->createQueryBuilder('n') + ->select('COUNT(n.id)') + ->where('n.user = :user') + ->andWhere('n.isRead = false') + ->setParameter('user', $user) + ->getQuery() + ->getSingleScalarResult() + ; + } + + public function markAllReadByUser(User $user): int + { + return $this->createQueryBuilder('n') + ->update() + ->set('n.isRead', 'true') + ->where('n.user = :user') + ->andWhere('n.isRead = false') + ->setParameter('user', $user) + ->getQuery() + ->executeStatement() + ; + } +} From d93235902486772063cc8e35c2df06b5740eec35 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:45:58 +0100 Subject: [PATCH 35/89] feat(notification) : add NotificationProvider filtered by current user --- src/State/NotificationProvider.php | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/State/NotificationProvider.php diff --git a/src/State/NotificationProvider.php b/src/State/NotificationProvider.php new file mode 100644 index 0000000..0a1b8b9 --- /dev/null +++ b/src/State/NotificationProvider.php @@ -0,0 +1,33 @@ + + */ +final readonly class NotificationProvider implements ProviderInterface +{ + public function __construct( + private Security $security, + private NotificationRepository $notificationRepository, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|object + { + $user = $this->security->getUser(); + + return $this->notificationRepository->findBy( + ['user' => $user], + ['createdAt' => 'DESC'], + 30, + ); + } +} From ce2eaa03e15f04c59281cad0238ca24531d64379 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:46:10 +0100 Subject: [PATCH 36/89] feat(notification) : add unread-count and mark-all-read custom controllers --- src/Controller/MarkAllReadController.php | 31 +++++++++++++++++++ .../NotificationUnreadCountController.php | 31 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/Controller/MarkAllReadController.php create mode 100644 src/Controller/NotificationUnreadCountController.php diff --git a/src/Controller/MarkAllReadController.php b/src/Controller/MarkAllReadController.php new file mode 100644 index 0000000..ece0497 --- /dev/null +++ b/src/Controller/MarkAllReadController.php @@ -0,0 +1,31 @@ +getUser(); + + $this->notificationRepository->markAllReadByUser($user); + + return new Response(null, Response::HTTP_NO_CONTENT); + } +} diff --git a/src/Controller/NotificationUnreadCountController.php b/src/Controller/NotificationUnreadCountController.php new file mode 100644 index 0000000..dc3669f --- /dev/null +++ b/src/Controller/NotificationUnreadCountController.php @@ -0,0 +1,31 @@ +getUser(); + + $count = $this->notificationRepository->countUnreadByUser($user); + + return new JsonResponse(['count' => $count]); + } +} From 4094048aba0b8db6e6bb7142124519850913d842 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:46:37 +0100 Subject: [PATCH 37/89] feat(notification) : add NotificationService and UserRepository::findByRole --- src/Repository/UserRepository.php | 13 +++++ src/Service/NotificationService.php | 74 +++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/Service/NotificationService.php diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index e85e4bf..d18497d 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -17,4 +17,17 @@ class UserRepository extends ServiceEntityRepository { parent::__construct($registry, User::class); } + + /** + * @return User[] + */ + public function findByRole(string $role): array + { + return $this->createQueryBuilder('u') + ->where('u.roles LIKE :role') + ->setParameter('role', '%"'.$role.'"%') + ->getQuery() + ->getResult() + ; + } } diff --git a/src/Service/NotificationService.php b/src/Service/NotificationService.php new file mode 100644 index 0000000..d2d9917 --- /dev/null +++ b/src/Service/NotificationService.php @@ -0,0 +1,74 @@ +userRepository->findByRole('ROLE_ADMIN'); + $number = sprintf('CT-%03d', $ticket->getNumber()); + $projectName = $ticket->getProject()?->getName() ?? ''; + + foreach ($admins as $admin) { + $notification = new Notification(); + $notification->setUser($admin); + $notification->setType('ticket_created'); + $notification->setTitle('Nouveau ticket client '.$number); + $notification->setMessage($ticket->getTitle().' — '.$projectName); + $notification->setRelatedTicket($ticket); + $notification->setCreatedAt(new DateTimeImmutable()); + + $this->entityManager->persist($notification); + } + + $this->entityManager->flush(); + } + + /** + * Notify the ticket submitter that the status has changed. + */ + public function createForStatusChange(ClientTicket $ticket): void + { + $submittedBy = $ticket->getSubmittedBy(); + + if (null === $submittedBy) { + return; + } + + $number = sprintf('CT-%03d', $ticket->getNumber()); + $statusLabel = $ticket->getStatus(); + $message = 'Nouveau statut : '.$statusLabel; + + if (null !== $ticket->getStatusComment() && '' !== $ticket->getStatusComment()) { + $message .= ' — '.$ticket->getStatusComment(); + } + + $notification = new Notification(); + $notification->setUser($submittedBy); + $notification->setType('ticket_status_changed'); + $notification->setTitle('Ticket '.$number.' mis à jour'); + $notification->setMessage($message); + $notification->setRelatedTicket($ticket); + $notification->setCreatedAt(new DateTimeImmutable()); + + $this->entityManager->persist($notification); + $this->entityManager->flush(); + } +} From 59b11f122533f06eb57957a03f0593ca87e9eae0 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:47:06 +0100 Subject: [PATCH 38/89] feat(notification) : hook NotificationService into ticket processors --- src/State/ClientTicketNumberProcessor.php | 4 ++++ src/State/ClientTicketStatusProcessor.php | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/State/ClientTicketNumberProcessor.php b/src/State/ClientTicketNumberProcessor.php index 2f1cae1..acfd677 100644 --- a/src/State/ClientTicketNumberProcessor.php +++ b/src/State/ClientTicketNumberProcessor.php @@ -9,6 +9,7 @@ use ApiPlatform\State\ProcessorInterface; use App\Entity\ClientTicket; use App\Entity\User; use App\Repository\ClientTicketRepository; +use App\Service\NotificationService; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\SecurityBundle\Security; @@ -24,6 +25,7 @@ final readonly class ClientTicketNumberProcessor implements ProcessorInterface private EntityManagerInterface $entityManager, private Security $security, private ClientTicketRepository $clientTicketRepository, + private NotificationService $notificationService, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket @@ -56,6 +58,8 @@ final readonly class ClientTicketNumberProcessor implements ProcessorInterface $this->entityManager->persist($data); $this->entityManager->flush(); + $this->notificationService->createForTicketCreated($data); + return $data; } } diff --git a/src/State/ClientTicketStatusProcessor.php b/src/State/ClientTicketStatusProcessor.php index 271834a..2b229f1 100644 --- a/src/State/ClientTicketStatusProcessor.php +++ b/src/State/ClientTicketStatusProcessor.php @@ -7,6 +7,7 @@ namespace App\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProcessorInterface; use App\Entity\ClientTicket; +use App\Service\NotificationService; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -23,6 +24,7 @@ final readonly class ClientTicketStatusProcessor implements ProcessorInterface public function __construct( private EntityManagerInterface $entityManager, + private NotificationService $notificationService, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket @@ -51,6 +53,8 @@ final readonly class ClientTicketStatusProcessor implements ProcessorInterface $this->entityManager->persist($data); $this->entityManager->flush(); + $this->notificationService->createForStatusChange($data); + return $data; } } From 587733e6f9ecf162b2e7f38bba433b48f51a7df3 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:47:21 +0100 Subject: [PATCH 39/89] feat(frontend) : add notification DTO and service --- frontend/services/dto/notification.ts | 13 +++++++++++ frontend/services/notifications.ts | 33 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 frontend/services/dto/notification.ts create mode 100644 frontend/services/notifications.ts diff --git a/frontend/services/dto/notification.ts b/frontend/services/dto/notification.ts new file mode 100644 index 0000000..4c8e293 --- /dev/null +++ b/frontend/services/dto/notification.ts @@ -0,0 +1,13 @@ +export type NotificationType = 'ticket_created' | 'ticket_status_changed' + +export type Notification = { + '@id'?: string + id: number + user: string + type: NotificationType + title: string + message: string + relatedTicket: string | null + isRead: boolean + createdAt: string +} diff --git a/frontend/services/notifications.ts b/frontend/services/notifications.ts new file mode 100644 index 0000000..1417259 --- /dev/null +++ b/frontend/services/notifications.ts @@ -0,0 +1,33 @@ +import type { Notification } from './dto/notification' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export function useNotificationService() { + const api = useApi() + + async function getAll(): Promise { + const data = await api.get>('/notifications') + return extractHydraMembers(data) + } + + async function markAsRead(id: number): Promise { + await api.patch(`/notifications/${id}`, { isRead: true }, { + toast: false, + }) + } + + async function markAllAsRead(): Promise { + await api.post('/notifications/mark-all-read', {}, { + toast: false, + }) + } + + async function getUnreadCount(): Promise { + const data = await api.get<{ count: number }>('/notifications/unread-count', {}, { + toast: false, + }) + return data.count + } + + return { getAll, markAsRead, markAllAsRead, getUnreadCount } +} From 697075eea2ccf3d715e4d90c8a293a15d49ae825 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:47:33 +0100 Subject: [PATCH 40/89] feat(frontend) : add useNotifications composable with polling --- frontend/composables/useNotifications.ts | 69 ++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 frontend/composables/useNotifications.ts diff --git a/frontend/composables/useNotifications.ts b/frontend/composables/useNotifications.ts new file mode 100644 index 0000000..e081b67 --- /dev/null +++ b/frontend/composables/useNotifications.ts @@ -0,0 +1,69 @@ +import type { Notification } from '~/services/dto/notification' +import { useNotificationService } from '~/services/notifications' + +const POLL_INTERVAL = 2 * 60 * 1000 // 2 minutes + +export function useNotifications() { + const unreadCount = useState('notification-unread-count', () => 0) + const notifications = useState('notification-list', () => []) + const isLoading = useState('notification-loading', () => false) + + const service = useNotificationService() + let pollTimer: ReturnType | null = null + + async function fetchUnreadCount(): Promise { + try { + unreadCount.value = await service.getUnreadCount() + } catch { + // Silently ignore polling errors + } + } + + async function fetchNotifications(): Promise { + isLoading.value = true + try { + notifications.value = await service.getAll() + } finally { + isLoading.value = false + } + } + + async function markAsRead(id: number): Promise { + await service.markAsRead(id) + const notif = notifications.value.find(n => n.id === id) + if (notif && !notif.isRead) { + notif.isRead = true + unreadCount.value = Math.max(0, unreadCount.value - 1) + } + } + + async function markAllAsRead(): Promise { + await service.markAllAsRead() + notifications.value.forEach(n => n.isRead = true) + unreadCount.value = 0 + } + + function startPolling(): void { + fetchUnreadCount() + pollTimer = setInterval(fetchUnreadCount, POLL_INTERVAL) + } + + function stopPolling(): void { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + } + + return { + unreadCount, + notifications, + isLoading, + fetchNotifications, + fetchUnreadCount, + markAsRead, + markAllAsRead, + startPolling, + stopPolling, + } +} From f7afe1c6fbbee06ca5f1d022a7433fa12966c350 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:47:43 +0100 Subject: [PATCH 41/89] docs : add MCP server section to CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index d108fb4..1640a80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,9 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4. src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration) src/ApiResource/ # Ressources API Platform (si découplées des entités) src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, Gitea*Provider, Gitea*Processor) +src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/) +src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP) +src/Command/ # Commandes console (GenerateApiTokenCommand) src/Repository/ # Repositories Doctrine src/DataFixtures/ # Fixtures config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine) @@ -80,8 +83,19 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` - Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`) - 4 espaces d'indentation +### MCP Server + +- 22 tools MCP exposant projets, tâches, métadonnées, et time tracking +- Transport STDIO (local) : `docker exec -i php-lesstime-fpm php bin/console mcp:server` +- Transport HTTP (réseau) : `POST /_mcp` avec header `Authorization: Bearer ` +- Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User` +- Générer un token : `php bin/console app:generate-api-token ` +- Config : `config/packages/mcp.yaml`, firewall dans `config/packages/security.yaml` +- Attribut `#[McpTool]` doit être sur la **classe** (pas la méthode `__invoke`) pour la discovery SDK + ### Nginx +- `/_mcp` → Symfony (MCP HTTP transport) - `/api/*` → Symfony (via try_files + index.php) - `/api/login_check` → location exact match, fastcgi direct avec REQUEST_URI réécrit en `/login_check` - `/` → SPA frontend (`frontend/dist/`) @@ -97,3 +111,4 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` ## Fixtures - User admin : `admin` / `admin` (ROLE_ADMIN) +- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production` From ad8142ac9d445b7aad4bf090ff7dfa0f9fd74b3b Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:48:03 +0100 Subject: [PATCH 42/89] feat(frontend) : add NotificationBell component with dropdown --- .../notification/NotificationBell.vue | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 frontend/components/notification/NotificationBell.vue diff --git a/frontend/components/notification/NotificationBell.vue b/frontend/components/notification/NotificationBell.vue new file mode 100644 index 0000000..9e71fed --- /dev/null +++ b/frontend/components/notification/NotificationBell.vue @@ -0,0 +1,171 @@ + + + + + From e63ed63dd8f83a6a55b9427a2b5cd16e7acf8b83 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:48:13 +0100 Subject: [PATCH 43/89] feat(frontend) : integrate NotificationBell in AppTopNav navbar --- frontend/components/ui/AppTopNav.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/components/ui/AppTopNav.vue b/frontend/components/ui/AppTopNav.vue index e4fdfd5..dceed54 100644 --- a/frontend/components/ui/AppTopNav.vue +++ b/frontend/components/ui/AppTopNav.vue @@ -7,7 +7,8 @@ > -

+
+
From 40d6f7693fe00f8d51281f44dee85819b4345dc4 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:48:27 +0100 Subject: [PATCH 44/89] feat(i18n) : add notification translations in French --- frontend/i18n/locales/fr.json | 37 ++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index c1cf58b..f977996 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -216,7 +216,13 @@ "portal": { "title": "Portail client", "projects": "Vos projets", - "noProjects": "Aucun projet disponible." + "noProjects": "Aucun projet disponible.", + "openTickets": "tickets ouverts", + "newTicket": "Nouveau ticket", + "ticketDetail": "Détail du ticket", + "backToProject": "Retour au projet", + "submitTicket": "Soumettre le ticket", + "ticketCreated": "Ticket soumis avec succès." }, "clientTicket": { "title": "Tickets", @@ -246,14 +252,35 @@ "confirmDelete": "Êtes-vous sûr de vouloir supprimer ce ticket ?", "rejectComment": "Commentaire de rejet", "rejectCommentRequired": "Un commentaire est requis pour rejeter un ticket.", - "linkedTicket": "Lié au ticket client CT-{number}" + "linkedTicket": "Lié au ticket client CT-{number}", + "description": "Description", + "url": "URL (page concernée)", + "statusComment": "Commentaire de statut", + "statusChanged": "Statut mis à jour", + "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.", + "linkedTooltip": "Lié au ticket client {number}", + "rejectionRequired": "Un commentaire est requis pour rejeter un ticket", + "noTickets": "Aucun ticket.", + "allStatuses": "Tous les statuts", + "allProjects": "Tous les projets", + "submittedBy": "Soumis par", + "createdAt": "Créé le", + "adminTab": "Tickets client", + "selectType": "Type de ticket", + "changeStatus": "Changer le statut" }, "notification": { "title": "Notifications", "markAllRead": "Tout marquer comme lu", - "empty": "Aucune notification.", - "ticketCreated": "Nouveau ticket", - "ticketStatusChanged": "Statut mis à jour" + "empty": "Aucune notification", + "ticketCreated": "Nouveau ticket client {number}", + "ticketStatusChanged": "Ticket {number} mis à jour", + "timeAgo": { + "now": "À l'instant", + "minutes": "Il y a {n} min", + "hours": "Il y a {n}h", + "days": "Il y a {n}j" + } }, "bookstack": { "settings": { From 17c5160f2c4420d7dccc485fa219e0f59ad6de66 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:49:57 +0100 Subject: [PATCH 45/89] docs : add MCP server documentation to README Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/README.md b/README.md index 02018e9..6cddce7 100644 --- a/README.md +++ b/README.md @@ -1 +1,71 @@ # Lesstime + +Application de gestion de projet. Symfony 8 + API Platform 4 + Nuxt 4. + +## MCP Server + +Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistants IA (Claude Code, ChatGPT, Codex) d'interagir avec les projets, tâches et le suivi du temps. + +### Tools disponibles (22) + +| Domaine | Tools | +|---------|-------| +| Reference | `list-users`, `list-clients` | +| Project | `list-projects`, `get-project`, `create-project`, `update-project` | +| Task | `list-tasks`, `get-task`, `create-task`, `update-task`, `delete-task` | +| TaskMeta | `list-statuses`, `list-priorities`, `list-efforts`, `list-tags`, `list-groups`, `create-group`, `update-group` | +| TimeEntry | `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry` | + +### Transports + +| Transport | Usage | Auth | +|-----------|-------|------| +| **STDIO** | Claude Code sur la machine locale | Aucune | +| **HTTP** (`/_mcp`) | Clients MCP sur le réseau local | API token (`Authorization: Bearer `) | + +### Configuration locale (STDIO) + +```json +{ + "mcpServers": { + "lesstime": { + "command": "docker", + "args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"] + } + } +} +``` + +### Configuration réseau (HTTP) + +```json +{ + "mcpServers": { + "lesstime": { + "type": "url", + "url": "http://:8082/_mcp", + "headers": { + "Authorization": "Bearer " + } + } + } +} +``` + +### Gestion des tokens API + +```bash +# Générer un token pour un utilisateur +docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token +``` + +### Mise en production (réseau local) + +1. Déployer le code sur le serveur +2. `composer install --no-dev --optimize-autoloader` +3. `php bin/console doctrine:migrations:migrate --no-interaction` +4. `php bin/console cache:clear --env=prod` +5. `docker restart nginx-lesstime` +6. `php bin/console app:generate-api-token admin` — noter le token +7. Ouvrir le port 8082 sur le firewall du serveur (LAN uniquement) +8. Configurer les clients MCP avec l'URL `http://:8082/_mcp` + le token From 0724d38a26e85322aa123c705a66ccd27badf592 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:51:51 +0100 Subject: [PATCH 46/89] feat(frontend) : add portal pages, update auth middleware and DTOs for client portal Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/layouts/portal.vue | 76 ++++++++++ frontend/middleware/auth.global.ts | 32 +++-- frontend/pages/portal/index.vue | 87 ++++++++++++ .../pages/portal/projects/[id]/new-ticket.vue | 132 ++++++++++++++++++ frontend/services/client-tickets.ts | 25 ++-- frontend/services/dto/client-ticket.ts | 10 +- frontend/services/dto/task.ts | 8 +- frontend/services/dto/user-data.ts | 6 +- 8 files changed, 347 insertions(+), 29 deletions(-) create mode 100644 frontend/layouts/portal.vue create mode 100644 frontend/pages/portal/index.vue create mode 100644 frontend/pages/portal/projects/[id]/new-ticket.vue diff --git a/frontend/layouts/portal.vue b/frontend/layouts/portal.vue new file mode 100644 index 0000000..c54346b --- /dev/null +++ b/frontend/layouts/portal.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/frontend/middleware/auth.global.ts b/frontend/middleware/auth.global.ts index 1060a90..c63ab55 100644 --- a/frontend/middleware/auth.global.ts +++ b/frontend/middleware/auth.global.ts @@ -1,16 +1,26 @@ export default defineNuxtRouteMiddleware(async (to) => { - const auth = useAuthStore() - const isLogin = to.path === '/login' + const auth = useAuthStore() + const isLogin = to.path === '/login' - if (!auth.checked) { - await auth.ensureSession() - } + if (!auth.checked) { + await auth.ensureSession() + } - if (!isLogin && !auth.isAuthenticated) { - return navigateTo('/login') - } + if (!isLogin && !auth.isAuthenticated) { + return navigateTo('/login') + } - if (isLogin && auth.isAuthenticated) { - return navigateTo('/') - } + if (isLogin && auth.isAuthenticated) { + const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false + return navigateTo(isClient ? '/portal' : '/') + } + + // ROLE_CLIENT: redirect to /portal, block internal pages + if (auth.isAuthenticated && auth.user?.roles?.includes('ROLE_CLIENT')) { + const isPortalRoute = to.path.startsWith('/portal') + const isLoginRoute = to.path === '/login' + if (!isPortalRoute && !isLoginRoute) { + return navigateTo('/portal') + } + } }) diff --git a/frontend/pages/portal/index.vue b/frontend/pages/portal/index.vue new file mode 100644 index 0000000..f9e4e01 --- /dev/null +++ b/frontend/pages/portal/index.vue @@ -0,0 +1,87 @@ + + + diff --git a/frontend/pages/portal/projects/[id]/new-ticket.vue b/frontend/pages/portal/projects/[id]/new-ticket.vue new file mode 100644 index 0000000..d7016bd --- /dev/null +++ b/frontend/pages/portal/projects/[id]/new-ticket.vue @@ -0,0 +1,132 @@ + + + diff --git a/frontend/services/client-tickets.ts b/frontend/services/client-tickets.ts index 18480f9..efda4eb 100644 --- a/frontend/services/client-tickets.ts +++ b/frontend/services/client-tickets.ts @@ -1,30 +1,31 @@ -import type { ClientTicket, ClientTicketWrite } from './dto/client-ticket' +import type { ClientTicket, ClientTicketWrite, ClientTicketStatusUpdate } from './dto/client-ticket' import type { HydraCollection } from '~/utils/api' import { extractHydraMembers } from '~/utils/api' export function useClientTicketService() { const api = useApi() - async function getAll(params?: Record): Promise { - const data = await api.get>('/client_tickets', params) + async function getAll(params?: { project?: number; status?: string; submittedBy?: number }): Promise { + const query: Record = {} + if (params?.project) query.project = `/api/projects/${params.project}` + if (params?.status) query.status = params.status + if (params?.submittedBy) query.submittedBy = `/api/users/${params.submittedBy}` + const data = await api.get>('/client_tickets', query) return extractHydraMembers(data) } async function getById(id: number): Promise { - return await api.get(`/client_tickets/${id}`) + return api.get(`/client_tickets/${id}`) } - async function create(data: ClientTicketWrite): Promise { - return await api.post('/client_tickets', data as Record, { - toastSuccessKey: 'clientTicket.created', + async function create(payload: ClientTicketWrite): Promise { + return api.post('/client_tickets', payload as Record, { + toastSuccessKey: 'portal.ticketCreated', }) } - async function updateStatus(id: number, status: string, statusComment?: string): Promise { - return await api.patch(`/client_tickets/${id}`, { - status, - ...(statusComment ? { statusComment } : {}), - }, { + async function updateStatus(id: number, payload: ClientTicketStatusUpdate): Promise { + return api.patch(`/client_tickets/${id}`, payload as Record, { toastSuccessKey: 'clientTicket.statusUpdated', }) } diff --git a/frontend/services/dto/client-ticket.ts b/frontend/services/dto/client-ticket.ts index af00fb3..542f8bb 100644 --- a/frontend/services/dto/client-ticket.ts +++ b/frontend/services/dto/client-ticket.ts @@ -1,5 +1,4 @@ import type { TaskDocument } from './task-document' -import type { UserData } from './user-data' export type ClientTicketType = 'bug' | 'improvement' | 'other' export type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected' @@ -15,10 +14,10 @@ export type ClientTicket = { status: ClientTicketStatus statusComment: string | null project: string - submittedBy: UserData | null + submittedBy: string | null createdAt: string updatedAt: string - documents: TaskDocument[] + documents?: TaskDocument[] } export type ClientTicketWrite = { @@ -28,3 +27,8 @@ export type ClientTicketWrite = { url?: string | null project: string } + +export type ClientTicketStatusUpdate = { + status: ClientTicketStatus + statusComment?: string | null +} diff --git a/frontend/services/dto/task.ts b/frontend/services/dto/task.ts index 770d579..abc2bc8 100644 --- a/frontend/services/dto/task.ts +++ b/frontend/services/dto/task.ts @@ -22,7 +22,13 @@ export type Task = { tags: TaskTag[] documents: TaskDocument[] archived: boolean - clientTicket?: { id: number; number: number; type: string; status: string; title: string } | null + clientTicket: { + id: number + number: number + type: string + status: string + title: string + } | null } export type TaskWrite = { diff --git a/frontend/services/dto/user-data.ts b/frontend/services/dto/user-data.ts index 0f618a8..c7d1163 100644 --- a/frontend/services/dto/user-data.ts +++ b/frontend/services/dto/user-data.ts @@ -1,10 +1,12 @@ +import type { Project } from './project' + export type UserData = { id: number '@id'?: string username: string roles: string[] - client?: { '@id'?: string; id: number; name: string } | null - allowedProjects?: { '@id'?: string; id: number; name: string }[] + client?: { id: number; name: string } | null + allowedProjects?: Project[] } export type UserWrite = { From cf1cf1ff5c361efca5ea7baf21a4394a644ece80 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 19:53:16 +0100 Subject: [PATCH 47/89] chore : add MCP bundle config files and .mcp.json for Claude Code Auto-generated by Symfony Flex recipe + .mcp.json for local STDIO transport. Co-Authored-By: Claude Opus 4.6 (1M context) --- .mcp.json | 8 ++++++++ config/bundles.php | 2 ++ config/reference.php | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..1c7cc70 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "lesstime": { + "command": "docker", + "args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"] + } + } +} diff --git a/config/bundles.php b/config/bundles.php index d995319..727ecc7 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -8,6 +8,7 @@ use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle; use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle; use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle; use Nelmio\CorsBundle\NelmioCorsBundle; +use Symfony\AI\McpBundle\McpBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\TwigBundle\TwigBundle; @@ -22,4 +23,5 @@ return [ ApiPlatformBundle::class => ['all' => true], DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], LexikJWTAuthenticationBundle::class => ['all' => true], + McpBundle::class => ['all' => true], ]; diff --git a/config/reference.php b/config/reference.php index 1ee83e3..354bc7f 100644 --- a/config/reference.php +++ b/config/reference.php @@ -1610,6 +1610,37 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * cache?: scalar|Param|null, // Storage to track blocked tokens // Default: "cache.app" * }, * } + * @psalm-type McpConfig = array{ + * app?: scalar|Param|null, // Default: "app" + * version?: scalar|Param|null, // Default: "0.0.1" + * description?: scalar|Param|null, // Default: null + * icons?: list, + * }>, + * website_url?: scalar|Param|null, // Default: null + * pagination_limit?: int|Param, // Default: 50 + * instructions?: scalar|Param|null, // Default: null + * client_transports?: array{ + * stdio?: bool|Param, // Default: false + * http?: bool|Param, // Default: false + * }, + * discovery?: array{ + * scan_dirs?: list, + * exclude_dirs?: list, + * }, + * http?: array{ + * path?: scalar|Param|null, // Default: "/_mcp" + * session?: array{ + * store?: "file"|"memory"|"cache"|Param, // Default: "file" + * directory?: scalar|Param|null, // Default: "%kernel.cache_dir%/mcp-sessions" + * cache_pool?: scalar|Param|null, // Default: "cache.mcp.sessions" + * prefix?: scalar|Param|null, // Default: "mcp-" + * ttl?: int|Param, // Default: 3600 + * }, + * }, + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1622,6 +1653,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * nelmio_cors?: NelmioCorsConfig, * api_platform?: ApiPlatformConfig, * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, + * mcp?: McpConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -1634,6 +1666,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * nelmio_cors?: NelmioCorsConfig, * api_platform?: ApiPlatformConfig, * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, + * mcp?: McpConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -1647,6 +1680,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * nelmio_cors?: NelmioCorsConfig, * api_platform?: ApiPlatformConfig, * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, + * mcp?: McpConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -1660,6 +1694,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param; * nelmio_cors?: NelmioCorsConfig, * api_platform?: ApiPlatformConfig, * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, + * mcp?: McpConfig, * }, * ... Date: Sun, 15 Mar 2026 19:59:47 +0100 Subject: [PATCH 48/89] fix(notification) : add route priority to prevent API Platform conflict Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Controller/MarkAllReadController.php | 2 +- src/Controller/NotificationUnreadCountController.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controller/MarkAllReadController.php b/src/Controller/MarkAllReadController.php index ece0497..a745051 100644 --- a/src/Controller/MarkAllReadController.php +++ b/src/Controller/MarkAllReadController.php @@ -17,7 +17,7 @@ class MarkAllReadController extends AbstractController private readonly NotificationRepository $notificationRepository, ) {} - #[Route('/api/notifications/mark-all-read', name: 'notification_mark_all_read', methods: ['POST'])] + #[Route('/api/notifications/mark-all-read', name: 'notification_mark_all_read', methods: ['POST'], priority: 1)] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function __invoke(): Response { diff --git a/src/Controller/NotificationUnreadCountController.php b/src/Controller/NotificationUnreadCountController.php index dc3669f..f4c6d82 100644 --- a/src/Controller/NotificationUnreadCountController.php +++ b/src/Controller/NotificationUnreadCountController.php @@ -17,7 +17,7 @@ class NotificationUnreadCountController extends AbstractController private readonly NotificationRepository $notificationRepository, ) {} - #[Route('/api/notifications/unread-count', name: 'notification_unread_count', methods: ['GET'])] + #[Route('/api/notifications/unread-count', name: 'notification_unread_count', methods: ['GET'], priority: 1)] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function __invoke(): JsonResponse { From f8748c4061016a0e33a100e0d6f693cc4e6eaf97 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 20:04:20 +0100 Subject: [PATCH 49/89] fix(portal) : handle ticket creation error and hide new ticket button for admins Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/pages/portal/projects/[id]/index.vue | 2 ++ frontend/pages/portal/projects/[id]/new-ticket.vue | 2 ++ 2 files changed, 4 insertions(+) diff --git a/frontend/pages/portal/projects/[id]/index.vue b/frontend/pages/portal/projects/[id]/index.vue index 54bd725..05aaff1 100644 --- a/frontend/pages/portal/projects/[id]/index.vue +++ b/frontend/pages/portal/projects/[id]/index.vue @@ -12,6 +12,7 @@

{{ projectName }}

@@ -99,6 +100,7 @@ const projectName = computed(() => { return '' }) +const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') ?? false) const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers() function openDetail(ticket: ClientTicket) { diff --git a/frontend/pages/portal/projects/[id]/new-ticket.vue b/frontend/pages/portal/projects/[id]/new-ticket.vue index d7016bd..f2fb52f 100644 --- a/frontend/pages/portal/projects/[id]/new-ticket.vue +++ b/frontend/pages/portal/projects/[id]/new-ticket.vue @@ -125,6 +125,8 @@ async function handleSubmit() { project: `/api/projects/${projectId.value}`, }) await navigateTo(`/portal/projects/${projectId.value}`) + } catch { + // Toast already shown by useApi } finally { isSubmitting.value = false } From 0c8fb654a9b841ac62c06ea067b3348c3d445faf Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 20:06:09 +0100 Subject: [PATCH 50/89] fix(portal) : allow admin+client users to access both views and add admin link Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/layouts/portal.vue | 11 +++++++++++ frontend/middleware/auth.global.ts | 8 ++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/layouts/portal.vue b/frontend/layouts/portal.vue index c54346b..5c43ca4 100644 --- a/frontend/layouts/portal.vue +++ b/frontend/layouts/portal.vue @@ -32,6 +32,15 @@ class="border-t border-secondary-500 pt-6" @click="ui.closeMobileSidebar()" /> +
@@ -58,6 +67,8 @@ const ui = useUiStore() const route = useRoute() const { version } = useAppVersion() +const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false) + // Close mobile sidebar on route change watch(() => route.path, () => { ui.closeMobileSidebar() diff --git a/frontend/middleware/auth.global.ts b/frontend/middleware/auth.global.ts index c63ab55..69e8499 100644 --- a/frontend/middleware/auth.global.ts +++ b/frontend/middleware/auth.global.ts @@ -11,12 +11,12 @@ export default defineNuxtRouteMiddleware(async (to) => { } if (isLogin && auth.isAuthenticated) { - const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false - return navigateTo(isClient ? '/portal' : '/') + const isClientOnly = auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN') + return navigateTo(isClientOnly ? '/portal' : '/') } - // ROLE_CLIENT: redirect to /portal, block internal pages - if (auth.isAuthenticated && auth.user?.roles?.includes('ROLE_CLIENT')) { + // ROLE_CLIENT without ROLE_ADMIN: redirect to /portal, block internal pages + if (auth.isAuthenticated && auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')) { const isPortalRoute = to.path.startsWith('/portal') const isLoginRoute = to.path === '/login' if (!isPortalRoute && !isLoginRoute) { From 6d7e6f5f486f29c93fef53bfd705412e5561fdfc Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 20:07:19 +0100 Subject: [PATCH 51/89] fix : allow admin users to create client tickets on any project Co-Authored-By: Claude Opus 4.6 (1M context) --- src/State/ClientTicketNumberProcessor.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/State/ClientTicketNumberProcessor.php b/src/State/ClientTicketNumberProcessor.php index acfd677..c985332 100644 --- a/src/State/ClientTicketNumberProcessor.php +++ b/src/State/ClientTicketNumberProcessor.php @@ -35,17 +35,20 @@ final readonly class ClientTicketNumberProcessor implements ProcessorInterface $user = $this->security->getUser(); assert($user instanceof User); - if (null === $user->getClient()) { - throw new AccessDeniedHttpException('Only client users can create tickets.'); - } - $project = $data->getProject(); if (null === $project) { throw new BadRequestHttpException('Project is required.'); } - if (!$user->getAllowedProjects()->contains($project)) { - throw new AccessDeniedHttpException('You do not have access to this project.'); + // Admins can create tickets on any project; clients only on allowed projects + if (!$this->security->isGranted('ROLE_ADMIN')) { + if (null === $user->getClient()) { + throw new AccessDeniedHttpException('Only client users can create tickets.'); + } + + if (!$user->getAllowedProjects()->contains($project)) { + throw new AccessDeniedHttpException('You do not have access to this project.'); + } } $nextNumber = $this->clientTicketRepository->findNextNumberForProject($project); From 6c910e7fccc32ec6db4d9420a9240a74a5e0f8b5 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 20:11:54 +0100 Subject: [PATCH 52/89] fix : use native SQL for JSON roles query in PostgreSQL Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Repository/UserRepository.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index d18497d..d0681e1 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -23,9 +23,17 @@ class UserRepository extends ServiceEntityRepository */ public function findByRole(string $role): array { + $conn = $this->getEntityManager()->getConnection(); + $sql = 'SELECT id FROM "user" WHERE roles::text LIKE :role'; + $ids = $conn->executeQuery($sql, ['role' => '%"'.$role.'"%'])->fetchFirstColumn(); + + if ([] === $ids) { + return []; + } + return $this->createQueryBuilder('u') - ->where('u.roles LIKE :role') - ->setParameter('role', '%"'.$role.'"%') + ->where('u.id IN (:ids)') + ->setParameter('ids', $ids) ->getQuery() ->getResult() ; From a2fc8e6e52caf1bf7e7cdc082716a183db6cd960 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 20:14:56 +0100 Subject: [PATCH 53/89] feat(task) : add client ticket selector in TaskModal Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/components/task/TaskModal.vue | 27 +++++++++++++++++++++++++- frontend/services/dto/task.ts | 1 + 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/frontend/components/task/TaskModal.vue b/frontend/components/task/TaskModal.vue index eabaec3..0ebdf95 100644 --- a/frontend/components/task/TaskModal.vue +++ b/frontend/components/task/TaskModal.vue @@ -102,6 +102,14 @@ empty-option-label="Aucun groupe" min-width="w-full" /> +
@@ -245,8 +253,10 @@ + + +``` + +- [ ] **Step 2: Verify the component renders** + +Run `make dev-nuxt` and navigate to the time-tracking page (integration comes in Task 4). Check that no build errors occur. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/components/ui/DateFilter.vue +git commit -m "feat(frontend) : create DateFilter reusable component" +``` + +--- + +## Chunk 2: Integration + +### Task 4: Integrate DateFilter into time-tracking page + +**Files:** +- Modify: `frontend/pages/time-tracking.vue:15-73` (template filter bar) +- Modify: `frontend/pages/time-tracking.vue:138` (add ref) +- Modify: `frontend/pages/time-tracking.vue:184-193` (filteredEntries computed) + +- [ ] **Step 1: Add the date filter ref** + +In `frontend/pages/time-tracking.vue`, after line 138 (`selectedProjectId`), add: + +```typescript +const selectedDateFilter = ref(null) +``` + +- [ ] **Step 2: Add DateFilter to the template filter bar** + +In the filter bar `
` (line 15), after the tag MalioSelect block (after line 72), add: + +```vue + +``` + +- [ ] **Step 3: Add date filtering to filteredEntries computed** + +In `frontend/pages/time-tracking.vue`, update the `filteredEntries` computed (around line 184) to include date filtering: + +```typescript +const filteredEntries = computed(() => { + let result = entries.value + if (selectedProjectId.value) { + result = result.filter((e) => e.project?.id === selectedProjectId.value) + } + if (selectedTagId.value) { + result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value)) + } + if (selectedDateFilter.value) { + if (Array.isArray(selectedDateFilter.value)) { + const [start, end] = selectedDateFilter.value + const startDay = new Date(start) + startDay.setHours(0, 0, 0, 0) + const endDay = new Date(end) + endDay.setHours(23, 59, 59, 999) + result = result.filter((e) => { + const entryDate = new Date(e.startedAt) + return entryDate >= startDay && entryDate <= endDay + }) + } else { + const day = new Date(selectedDateFilter.value) + day.setHours(0, 0, 0, 0) + const nextDay = new Date(day) + nextDay.setDate(nextDay.getDate() + 1) + result = result.filter((e) => { + const entryDate = new Date(e.startedAt) + return entryDate >= day && entryDate < nextDay + }) + } + } + return result +}) +``` + +- [ ] **Step 4: Verify manually** + +Run `make dev-nuxt`, navigate to time-tracking page: +1. Verify DateFilter appears in the filter bar +2. Click a single day — entries filter to that day +3. Click a second day — entries filter to the range +4. Click "Aujourd'hui" — filters to today +5. Click "Cette semaine" — filters to current week +6. Clear the filter — all entries show again + +- [ ] **Step 5: Commit** + +```bash +git add frontend/pages/time-tracking.vue +git commit -m "feat(frontend) : integrate date filter into time-tracking page" +``` diff --git a/docs/superpowers/specs/2026-03-15-date-filter-design.md b/docs/superpowers/specs/2026-03-15-date-filter-design.md new file mode 100644 index 0000000..df611d8 --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-date-filter-design.md @@ -0,0 +1,86 @@ +# Date Filter Component - Design Spec + +## Summary + +Add a reusable date filter component to the time-tracking page using `@vuepic/vue-datepicker`. Allows filtering by single day or date range via text input and mini calendar dropdown. + +## Behavior + +- **Single click** on a day = select that day +- **Second click** on another day = select range between the two dates +- **Text input**: type a date (`15/03/2026`) or a range (`15/03/2026 - 20/03/2026`) +- **Calendar dropdown**: opens on input click/focus +- **Quick shortcuts**: "Aujourd'hui" and "Cette semaine" buttons in calendar +- **No time picker**: filter by day granularity only +- **Format**: `dd/MM/yyyy` (French locale) + +## Component: `DateFilter.vue` + +Location: `frontend/components/ui/DateFilter.vue` + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `modelValue` | `Date \| [Date, Date] \| null` | `null` | Selected date or range | +| `placeholder` | `string` | `t('common.dateFilter')` | Input placeholder | + +### Emits + +| Event | Payload | Description | +|-------|---------|-------------| +| `update:modelValue` | `Date \| [Date, Date] \| null` | Date selection changed | + +### Implementation + +- Wraps `VueDatePicker` with project-consistent styling +- Uses `#dp-input` slot for custom input matching MalioSelect style +- Configures `range` mode with `multi-calendars: false` +- Sets `text-input` with `format: 'dd/MM/yyyy'`, `rangeSeparator: ' - '` +- Disables time picker (`enable-time-picker: false`) +- Applies project primary color (`#222783`) via CSS overrides +- Responsive width: `!w-44 sm:!w-52` + +## Integration: Time Tracking Page + +### Filter bar addition + +Add `DateFilter` to the existing filter bar in `frontend/pages/time-tracking.vue`, alongside user/project/tag filters. + +### Filtering logic + +- Client-side filtering (same pattern as project and tag filters) +- When a single date is selected: show only entries matching that day +- When a range is selected: show entries within the range (inclusive) +- When null: show all entries (no date filter) + +## Files Impacted + +| File | Action | Description | +|------|--------|-------------| +| `frontend/components/ui/DateFilter.vue` | Create | Reusable date filter wrapper | +| `frontend/nuxt.config.ts` | Modify | Add `@vuepic/vue-datepicker` to `build.transpile` | +| `frontend/pages/time-tracking.vue` | Modify | Integrate DateFilter in filter bar + client-side filtering | +| `frontend/i18n/locales/fr.json` | Modify | Add French translations | +| `frontend/i18n/locales/en.json` | Modify | Add English translations | +| `package.json` | Modify | Add `@vuepic/vue-datepicker` dependency | + +## i18n Keys + +```json +{ + "common": { + "dateFilter": "Date", + "today": "Aujourd'hui", + "thisWeek": "Cette semaine" + } +} +``` + +## Style + +- Input height and borders match MalioSelect components +- Text size: `text-sm` +- Selected date highlight: project primary color `#222783` +- Calendar dropdown: subtle shadow, rounded corners matching project style +- Override default vue-datepicker CSS variables to match project theme diff --git a/frontend/components/ui/DateFilter.vue b/frontend/components/ui/DateFilter.vue new file mode 100644 index 0000000..c416202 --- /dev/null +++ b/frontend/components/ui/DateFilter.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index f977996..f71191e 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -166,7 +166,11 @@ }, "common": { "cancel": "Annuler", - "loading": "Chargement..." + "loading": "Chargement...", + "dateFilter": "Date", + "today": "Aujourd'hui", + "thisWeek": "Cette semaine", + "clear": "Effacer" }, "gitea": { "settings": { diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 69c96c8..ebec92b 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -62,5 +62,8 @@ export default defineNuxtConfig({ }, typescript: { strict: true + }, + build: { + transpile: ['@vuepic/vue-datepicker'] } }) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index db1ab76..4ba6645 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", "@pinia/nuxt": "^0.11.3", + "@vuepic/vue-datepicker": "^12.1.0", "chart.js": "^4.5.1", "nuxt": "^4.3.1", "nuxt-toast": "^1.4.0", @@ -541,6 +542,12 @@ "postcss-selector-parser": "^7.0.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@dxup/nuxt": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@dxup/nuxt/-/nuxt-0.3.2.tgz", @@ -1094,6 +1101,68 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@floating-ui/vue": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@floating-ui/vue/-/vue-1.1.11.tgz", + "integrity": "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6", + "@floating-ui/utils": "^0.2.11", + "vue-demi": ">=0.13.0" + } + }, + "node_modules/@floating-ui/vue/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -5259,6 +5328,12 @@ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "license": "MIT" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, "node_modules/@typescript-eslint/project-service": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", @@ -5720,6 +5795,62 @@ "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", "license": "MIT" }, + "node_modules/@vuepic/vue-datepicker": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-12.1.0.tgz", + "integrity": "sha512-QuWcO+CqIGYFoRNCagp9xUY9sMK/OHUlVIDxBYjw7HjCTWXfuE/r3l3loB00faEtb0Teo3DeBn26hT3tYA5pgg==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "@floating-ui/vue": "^1.1.9", + "@vueuse/core": "^14.1.0", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "vue": ">=3.5.0" + } + }, + "node_modules/@vueuse/core": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", + "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.2.1", + "@vueuse/shared": "14.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", + "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", + "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/abbrev": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", @@ -7126,6 +7257,16 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/db0": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index ef4aff3..bccdaab 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", "@pinia/nuxt": "^0.11.3", + "@vuepic/vue-datepicker": "^12.1.0", "chart.js": "^4.5.1", "nuxt": "^4.3.1", "nuxt-toast": "^1.4.0", diff --git a/frontend/pages/projects/[id]/index.vue b/frontend/pages/projects/[id]/index.vue index 5bd364d..5d24a9d 100644 --- a/frontend/pages/projects/[id]/index.vue +++ b/frontend/pages/projects/[id]/index.vue @@ -3,13 +3,22 @@

{{ project?.name ?? '' }}

- +
+ + +
@@ -120,6 +129,13 @@ @saved="onSaved" /> + +
@@ -132,7 +148,9 @@ import type { TaskPriority } from '~/services/dto/task-priority' import type { TaskTag } from '~/services/dto/task-tag' import type { TaskGroup } from '~/services/dto/task-group' import type { UserData } from '~/services/dto/user-data' +import type { Client } from '~/services/dto/client' import { useProjectService } from '~/services/projects' +import { useClientService } from '~/services/clients' import { useTaskService } from '~/services/tasks' import { useTaskStatusService } from '~/services/task-statuses' import { useTaskEffortService } from '~/services/task-efforts' @@ -147,6 +165,7 @@ const projectId = computed(() => Number(route.params.id)) useHead({ title: 'Projet' }) const projectService = useProjectService() +const clientService = useClientService() const taskService = useTaskService() const statusService = useTaskStatusService() const effortService = useTaskEffortService() @@ -163,6 +182,7 @@ const priorities = ref([]) const tags = ref([]) const groups = ref([]) const users = ref([]) +const clients = ref([]) const isLoading = ref(true) const selectedGroupId = ref(null) @@ -172,6 +192,7 @@ const selectedStatusId = ref(null) const dragOverStatusId = ref(null) const dragCounter = ref(0) const taskDrawerOpen = ref(false) +const projectDrawerOpen = ref(false) const selectedTask = ref(null) const groupFilterOptions = computed(() => @@ -218,7 +239,7 @@ const backlogTasks = computed(() => async function loadData() { isLoading.value = true try { - const [p, t, s, e, pr, ty, g, u] = await Promise.all([ + const [p, t, s, e, pr, ty, g, u, c] = await Promise.all([ projectService.getById(projectId.value), taskService.getByProject(projectId.value), statusService.getAll(), @@ -227,6 +248,7 @@ async function loadData() { tagService.getAll(), groupService.getByProject(projectId.value), userService.getAll(), + clientService.getAll(), ]) project.value = p tasks.value = t @@ -236,6 +258,7 @@ async function loadData() { tags.value = ty groups.value = g users.value = u + clients.value = c } finally { isLoading.value = false } @@ -290,6 +313,10 @@ async function onSaved() { await loadData() } +async function onProjectSaved() { + await loadData() +} + onMounted(() => { loadData() }) diff --git a/frontend/pages/time-tracking.vue b/frontend/pages/time-tracking.vue index b567208..c3518d9 100644 --- a/frontend/pages/time-tracking.vue +++ b/frontend/pages/time-tracking.vue @@ -70,6 +70,8 @@ text-value="text-sm" />
+ +
@@ -136,6 +138,7 @@ const startDate = ref(getMonday(new Date())) const selectedUserId = ref(authStore.user?.id ?? null) const selectedTagId = ref(null) const selectedProjectId = ref(null) +const selectedDateFilter = ref(null) const entries = ref([]) const users = ref([]) @@ -189,6 +192,28 @@ const filteredEntries = computed(() => { if (selectedTagId.value) { result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value)) } + if (selectedDateFilter.value) { + if (Array.isArray(selectedDateFilter.value)) { + const [start, end] = selectedDateFilter.value + const startDay = new Date(start) + startDay.setHours(0, 0, 0, 0) + const endDay = new Date(end) + endDay.setHours(23, 59, 59, 999) + result = result.filter((e) => { + const entryDate = new Date(e.startedAt) + return entryDate >= startDay && entryDate <= endDay + }) + } else { + const day = new Date(selectedDateFilter.value) + day.setHours(0, 0, 0, 0) + const nextDay = new Date(day) + nextDay.setDate(nextDay.getDate() + 1) + result = result.filter((e) => { + const entryDate = new Date(e.startedAt) + return entryDate >= day && entryDate < nextDay + }) + } + } return result }) From 2a874046d327ef88363c65dd7ada61c51ded5a85 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 20:35:11 +0100 Subject: [PATCH 57/89] feat : allow client to edit own tickets and protect status fields Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Entity/ClientTicket.php | 2 +- src/State/ClientTicketStatusProcessor.php | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Entity/ClientTicket.php b/src/Entity/ClientTicket.php index ac7d5db..e4daf47 100644 --- a/src/Entity/ClientTicket.php +++ b/src/Entity/ClientTicket.php @@ -36,7 +36,7 @@ use Symfony\Component\Serializer\Attribute\Groups; processor: ClientTicketNumberProcessor::class, ), new Patch( - security: "is_granted('ROLE_ADMIN')", + security: "is_granted('ROLE_ADMIN') or (is_granted('ROLE_CLIENT') and object.getSubmittedBy() == user)", processor: ClientTicketStatusProcessor::class, ), new Delete(security: "is_granted('ROLE_ADMIN')"), diff --git a/src/State/ClientTicketStatusProcessor.php b/src/State/ClientTicketStatusProcessor.php index 2b229f1..747f295 100644 --- a/src/State/ClientTicketStatusProcessor.php +++ b/src/State/ClientTicketStatusProcessor.php @@ -10,6 +10,7 @@ use App\Entity\ClientTicket; use App\Service\NotificationService; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; /** @@ -25,6 +26,7 @@ final readonly class ClientTicketStatusProcessor implements ProcessorInterface public function __construct( private EntityManagerInterface $entityManager, private NotificationService $notificationService, + private Security $security, ) {} public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket @@ -32,6 +34,13 @@ final readonly class ClientTicketStatusProcessor implements ProcessorInterface assert($data instanceof ClientTicket); $originalData = $context['previous_data'] ?? null; + + // ROLE_CLIENT: can only edit content fields, not status + if (!$this->security->isGranted('ROLE_ADMIN') && $originalData instanceof ClientTicket) { + $data->setStatus($originalData->getStatus()); + $data->setStatusComment($originalData->getStatusComment()); + } + if ($originalData instanceof ClientTicket) { $oldStatus = $originalData->getStatus(); $newStatus = $data->getStatus(); From d2f6d84d03c9efbdc7cd3e87422b36566f21b644 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 20:41:23 +0100 Subject: [PATCH 58/89] feat(portal) : replace ticket list with kanban board Co-Authored-By: Claude Sonnet 4.6 --- frontend/pages/portal/projects/[id]/index.vue | 80 +++++++++++++------ 1 file changed, 56 insertions(+), 24 deletions(-) diff --git a/frontend/pages/portal/projects/[id]/index.vue b/frontend/pages/portal/projects/[id]/index.vue index 05aaff1..a5a82ac 100644 --- a/frontend/pages/portal/projects/[id]/index.vue +++ b/frontend/pages/portal/projects/[id]/index.vue @@ -30,34 +30,46 @@ {{ $t('clientTicket.noTickets') }}
-
+ +
-
-
- CT-{{ String(ticket.number).padStart(3, '0') }} - - {{ $t(`clientTicket.type.${ticket.type}`) }} - +
+
+

{{ col.label }}

+ + {{ col.tickets.length }} + +
+
+
+
+ CT-{{ String(ticket.number).padStart(3, '0') }} + + {{ $t(`clientTicket.type.${ticket.type}`) }} + +
+

{{ ticket.title }}

+

{{ formatDate(ticket.createdAt) }}

-

{{ ticket.title }}

-

- {{ formatDate(ticket.createdAt) }} +

+ {{ $t('clientTicket.noTickets') }}

- - {{ $t(`clientTicket.status.${ticket.status}`) }} -
@@ -65,6 +77,7 @@
@@ -101,7 +114,26 @@ const projectName = computed(() => { }) const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') ?? false) -const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers() +const { typeBadgeClass, formatDate } = useClientTicketHelpers() + +const statuses = ['new', 'in_progress', 'done', 'rejected'] as const + +function statusDotClass(status: string): string { + switch (status) { + case 'new': return 'bg-blue-500' + case 'in_progress': return 'bg-yellow-500' + case 'done': return 'bg-green-500' + case 'rejected': return 'bg-red-500' + default: return 'bg-neutral-400' + } +} + +const columns = computed(() => statuses.map(status => ({ + status, + label: t(`clientTicket.status.${status}`), + dotClass: statusDotClass(status), + tickets: tickets.value.filter(tk => tk.status === status), +}))) function openDetail(ticket: ClientTicket) { selectedTicket.value = ticket From ffe4a0117c0d69d2aa17f6b0199b904db22284a3 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sun, 15 Mar 2026 20:41:25 +0100 Subject: [PATCH 59/89] feat(portal) : allow client to edit own tickets Co-Authored-By: Claude Sonnet 4.6 --- .../client-ticket/ClientTicketDetailModal.vue | 275 +++++++++++++----- frontend/i18n/locales/fr.json | 2 + frontend/services/client-tickets.ts | 8 +- 3 files changed, 213 insertions(+), 72 deletions(-) diff --git a/frontend/components/client-ticket/ClientTicketDetailModal.vue b/frontend/components/client-ticket/ClientTicketDetailModal.vue index 8dc1b21..28fdb09 100644 --- a/frontend/components/client-ticket/ClientTicketDetailModal.vue +++ b/frontend/components/client-ticket/ClientTicketDetailModal.vue @@ -27,90 +27,161 @@ {{ $t('portal.ticketDetail') }}

- +
+ + + +
- -

{{ ticket.title }}

- -
- - {{ $t(`clientTicket.type.${ticket.type}`) }} - - - {{ $t(`clientTicket.status.${ticket.status}`) }} - -
+ +