Compare commits
1 Commits
6c910e7fcc
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4216f1b5a1 |
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"lesstime": {
|
||||
"command": "docker",
|
||||
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
15
CLAUDE.md
15
CLAUDE.md
@@ -15,9 +15,6 @@ 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)
|
||||
@@ -83,19 +80,8 @@ 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 <token>`
|
||||
- Auth HTTP : `ApiTokenAuthenticator` vérifie le champ `apiToken` de l'entité `User`
|
||||
- Générer un token : `php bin/console app:generate-api-token <username>`
|
||||
- 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/`)
|
||||
@@ -111,4 +97,3 @@ 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`
|
||||
|
||||
70
README.md
70
README.md
@@ -1,71 +1 @@
|
||||
# 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 <token>`) |
|
||||
|
||||
### 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://<ip-serveur>:8082/_mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <api-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 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 <username>
|
||||
```
|
||||
|
||||
### 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://<ip-serveur>:8082/_mcp` + le token
|
||||
|
||||
@@ -14,8 +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",
|
||||
"phpdocumentor/reflection-docblock": "^6.0",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"symfony/asset": "8.0.*",
|
||||
"symfony/console": "8.0.*",
|
||||
@@ -24,7 +23,6 @@
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/http-client": "8.0.*",
|
||||
"symfony/mcp-bundle": "^0.6.0",
|
||||
"symfony/property-access": "8.0.*",
|
||||
"symfony/property-info": "8.0.*",
|
||||
"symfony/runtime": "8.0.*",
|
||||
|
||||
864
composer.lock
generated
864
composer.lock
generated
@@ -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": "75b9dbecf38167d0554dfd64a986a40e",
|
||||
"content-hash": "4790d8c80c0fb208e5af11fb205c0202",
|
||||
"packages": [
|
||||
{
|
||||
"name": "api-platform/doctrine-common",
|
||||
@@ -2549,82 +2549,6 @@
|
||||
],
|
||||
"time": "2025-12-20T17:47:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mcp/sdk",
|
||||
"version": "v0.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/modelcontextprotocol/php-sdk.git",
|
||||
"reference": "1f5f7e16a3af23dd43ec0a5c972d7aa8e8429024"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/modelcontextprotocol/php-sdk/zipball/1f5f7e16a3af23dd43ec0a5c972d7aa8e8429024",
|
||||
"reference": "1f5f7e16a3af23dd43ec0a5c972d7aa8e8429024",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-fileinfo": "*",
|
||||
"opis/json-schema": "^2.4",
|
||||
"php": "^8.1",
|
||||
"php-http/discovery": "^1.20",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"psr/clock": "^1.0",
|
||||
"psr/container": "^1.0 || ^2.0",
|
||||
"psr/event-dispatcher": "^1.0",
|
||||
"psr/http-factory": "^1.1",
|
||||
"psr/http-message": "^1.1 || ^2.0",
|
||||
"psr/http-server-handler": "^1.0",
|
||||
"psr/http-server-middleware": "^1.0",
|
||||
"psr/log": "^1.0 || ^2.0 || ^3.0",
|
||||
"symfony/finder": "^5.4 || ^6.4 || ^7.3 || ^8.0",
|
||||
"symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"laminas/laminas-httphandlerrunner": "^2.12",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"nyholm/psr7-server": "^1.1",
|
||||
"phar-io/composer-distributor": "^1.0.2",
|
||||
"php-cs-fixer/shim": "^3.91",
|
||||
"phpdocumentor/shim": "^3",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"psr/simple-cache": "^2.0 || ^3.0",
|
||||
"symfony/cache": "^5.4 || ^6.4 || ^7.3 || ^8.0",
|
||||
"symfony/console": "^5.4 || ^6.4 || ^7.3 || ^8.0",
|
||||
"symfony/process": "^5.4 || ^6.4 || ^7.3 || ^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Mcp\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christopher Hertel",
|
||||
"email": "mail@christopher-hertel.de"
|
||||
},
|
||||
{
|
||||
"name": "Kyrian Obikwelu",
|
||||
"email": "koshnawaza@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Tobias Nyholm",
|
||||
"email": "tobias.nyholm@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Model Context Protocol SDK for Client and Server applications in PHP",
|
||||
"support": {
|
||||
"issues": "https://github.com/modelcontextprotocol/php-sdk/issues",
|
||||
"source": "https://github.com/modelcontextprotocol/php-sdk/tree/v0.4.0"
|
||||
},
|
||||
"time": "2026-02-23T21:42:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nelmio/cors-bundle",
|
||||
"version": "2.6.1",
|
||||
@@ -2690,353 +2614,6 @@
|
||||
},
|
||||
"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",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/opis/json-schema.git",
|
||||
"reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/opis/json-schema/zipball/8458763e0dd0b6baa310e04f1829fc73da4e8c8a",
|
||||
"reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"opis/string": "^2.1",
|
||||
"opis/uri": "^1.0",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-bcmath": "*",
|
||||
"ext-intl": "*",
|
||||
"phpunit/phpunit": "^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Opis\\JsonSchema\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sorin Sarca",
|
||||
"email": "sarca_sorin@hotmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Marius Sarca",
|
||||
"email": "marius.sarca@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Json Schema Validator for PHP",
|
||||
"homepage": "https://opis.io/json-schema",
|
||||
"keywords": [
|
||||
"json",
|
||||
"json-schema",
|
||||
"schema",
|
||||
"validation",
|
||||
"validator"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/opis/json-schema/issues",
|
||||
"source": "https://github.com/opis/json-schema/tree/2.6.0"
|
||||
},
|
||||
"time": "2025-10-17T12:46:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "opis/string",
|
||||
"version": "2.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/opis/string.git",
|
||||
"reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e",
|
||||
"reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"ext-json": "*",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Opis\\String\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Marius Sarca",
|
||||
"email": "marius.sarca@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Sorin Sarca",
|
||||
"email": "sarca_sorin@hotmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Multibyte strings as objects",
|
||||
"homepage": "https://opis.io/string",
|
||||
"keywords": [
|
||||
"multi-byte",
|
||||
"opis",
|
||||
"string",
|
||||
"string manipulation",
|
||||
"utf-8"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/opis/string/issues",
|
||||
"source": "https://github.com/opis/string/tree/2.1.0"
|
||||
},
|
||||
"time": "2025-10-17T12:38:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "opis/uri",
|
||||
"version": "1.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/opis/uri.git",
|
||||
"reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a",
|
||||
"reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"opis/string": "^2.0",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Opis\\Uri\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"Apache-2.0"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Marius Sarca",
|
||||
"email": "marius.sarca@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Sorin Sarca",
|
||||
"email": "sarca_sorin@hotmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Build, parse and validate URIs and URI-templates",
|
||||
"homepage": "https://opis.io",
|
||||
"keywords": [
|
||||
"URI Template",
|
||||
"parse url",
|
||||
"punycode",
|
||||
"uri",
|
||||
"uri components",
|
||||
"url",
|
||||
"validate uri"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/opis/uri/issues",
|
||||
"source": "https://github.com/opis/uri/tree/1.1.0"
|
||||
},
|
||||
"time": "2021-05-22T15:57:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "php-http/discovery",
|
||||
"version": "1.20.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-http/discovery.git",
|
||||
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
|
||||
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-plugin-api": "^1.0|^2.0",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"nyholm/psr7": "<1.0",
|
||||
"zendframework/zend-diactoros": "*"
|
||||
},
|
||||
"provide": {
|
||||
"php-http/async-client-implementation": "*",
|
||||
"php-http/client-implementation": "*",
|
||||
"psr/http-client-implementation": "*",
|
||||
"psr/http-factory-implementation": "*",
|
||||
"psr/http-message-implementation": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"composer/composer": "^1.0.2|^2.0",
|
||||
"graham-campbell/phpspec-skip-example-extension": "^5.0",
|
||||
"php-http/httplug": "^1.0 || ^2.0",
|
||||
"php-http/message-factory": "^1.0",
|
||||
"phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
|
||||
"sebastian/comparator": "^3.0.5 || ^4.0.8",
|
||||
"symfony/phpunit-bridge": "^6.4.4 || ^7.0.1"
|
||||
},
|
||||
"type": "composer-plugin",
|
||||
"extra": {
|
||||
"class": "Http\\Discovery\\Composer\\Plugin",
|
||||
"plugin-optional": true
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Http\\Discovery\\": "src/"
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"src/Composer/Plugin.php"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Márk Sági-Kazár",
|
||||
"email": "mark.sagikazar@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
|
||||
"homepage": "http://php-http.org",
|
||||
"keywords": [
|
||||
"adapter",
|
||||
"client",
|
||||
"discovery",
|
||||
"factory",
|
||||
"http",
|
||||
"message",
|
||||
"psr17",
|
||||
"psr7"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/php-http/discovery/issues",
|
||||
"source": "https://github.com/php-http/discovery/tree/1.20.0"
|
||||
},
|
||||
"time": "2024-10-02T11:20:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpdocumentor/reflection-common",
|
||||
"version": "2.2.0",
|
||||
@@ -3092,16 +2669,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpdocumentor/reflection-docblock",
|
||||
"version": "5.6.6",
|
||||
"version": "6.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
|
||||
"reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8"
|
||||
"reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8",
|
||||
"reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8",
|
||||
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/897b5986ece6b4f9d8413fea345c7d49c757d6bf",
|
||||
"reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3109,8 +2686,8 @@
|
||||
"ext-filter": "*",
|
||||
"php": "^7.4 || ^8.0",
|
||||
"phpdocumentor/reflection-common": "^2.2",
|
||||
"phpdocumentor/type-resolver": "^1.7",
|
||||
"phpstan/phpdoc-parser": "^1.7|^2.0",
|
||||
"phpdocumentor/type-resolver": "^2.0",
|
||||
"phpstan/phpdoc-parser": "^2.0",
|
||||
"webmozart/assert": "^1.9.1 || ^2"
|
||||
},
|
||||
"require-dev": {
|
||||
@@ -3120,7 +2697,8 @@
|
||||
"phpstan/phpstan-mockery": "^1.1",
|
||||
"phpstan/phpstan-webmozart-assert": "^1.2",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"psalm/phar": "^5.26"
|
||||
"psalm/phar": "^5.26",
|
||||
"shipmonk/dead-code-detector": "^0.5.1"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
@@ -3150,44 +2728,44 @@
|
||||
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
|
||||
"support": {
|
||||
"issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
|
||||
"source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6"
|
||||
"source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.2"
|
||||
},
|
||||
"time": "2025-12-22T21:13:58+00:00"
|
||||
"time": "2026-03-01T18:43:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpdocumentor/type-resolver",
|
||||
"version": "1.12.0",
|
||||
"version": "2.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpDocumentor/TypeResolver.git",
|
||||
"reference": "92a98ada2b93d9b201a613cb5a33584dde25f195"
|
||||
"reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195",
|
||||
"reference": "92a98ada2b93d9b201a613cb5a33584dde25f195",
|
||||
"url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9",
|
||||
"reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"doctrine/deprecations": "^1.0",
|
||||
"php": "^7.3 || ^8.0",
|
||||
"php": "^7.4 || ^8.0",
|
||||
"phpdocumentor/reflection-common": "^2.0",
|
||||
"phpstan/phpdoc-parser": "^1.18|^2.0"
|
||||
"phpstan/phpdoc-parser": "^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-tokenizer": "*",
|
||||
"phpbench/phpbench": "^1.2",
|
||||
"phpstan/extension-installer": "^1.1",
|
||||
"phpstan/phpstan": "^1.8",
|
||||
"phpstan/phpstan-phpunit": "^1.1",
|
||||
"phpstan/extension-installer": "^1.4",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"phpunit/phpunit": "^9.5",
|
||||
"rector/rector": "^0.13.9",
|
||||
"vimeo/psalm": "^4.25"
|
||||
"psalm/phar": "^4"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-1.x": "1.x-dev"
|
||||
"dev-1.x": "1.x-dev",
|
||||
"dev-2.x": "2.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -3208,9 +2786,9 @@
|
||||
"description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
|
||||
"support": {
|
||||
"issues": "https://github.com/phpDocumentor/TypeResolver/issues",
|
||||
"source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0"
|
||||
"source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0"
|
||||
},
|
||||
"time": "2025-11-21T15:09:14+00:00"
|
||||
"time": "2026-01-06T21:53:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpdoc-parser",
|
||||
@@ -3459,227 +3037,6 @@
|
||||
},
|
||||
"time": "2019-01-08T18:20:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/http-factory",
|
||||
"version": "1.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/http-factory.git",
|
||||
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
|
||||
"reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1",
|
||||
"psr/http-message": "^1.0 || ^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Http\\Message\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
|
||||
"keywords": [
|
||||
"factory",
|
||||
"http",
|
||||
"message",
|
||||
"psr",
|
||||
"psr-17",
|
||||
"psr-7",
|
||||
"request",
|
||||
"response"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/http-factory"
|
||||
},
|
||||
"time": "2024-04-15T12:06:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/http-message",
|
||||
"version": "2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/http-message.git",
|
||||
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
|
||||
"reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Http\\Message\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for HTTP messages",
|
||||
"homepage": "https://github.com/php-fig/http-message",
|
||||
"keywords": [
|
||||
"http",
|
||||
"http-message",
|
||||
"psr",
|
||||
"psr-7",
|
||||
"request",
|
||||
"response"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/http-message/tree/2.0"
|
||||
},
|
||||
"time": "2023-04-04T09:54:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/http-server-handler",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/http-server-handler.git",
|
||||
"reference": "84c4fb66179be4caaf8e97bd239203245302e7d4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4",
|
||||
"reference": "84c4fb66179be4caaf8e97bd239203245302e7d4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.0",
|
||||
"psr/http-message": "^1.0 || ^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Http\\Server\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for HTTP server-side request handler",
|
||||
"keywords": [
|
||||
"handler",
|
||||
"http",
|
||||
"http-interop",
|
||||
"psr",
|
||||
"psr-15",
|
||||
"psr-7",
|
||||
"request",
|
||||
"response",
|
||||
"server"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/http-server-handler/tree/1.0.2"
|
||||
},
|
||||
"time": "2023-04-10T20:06:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/http-server-middleware",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/http-server-middleware.git",
|
||||
"reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
|
||||
"reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.0",
|
||||
"psr/http-message": "^1.0 || ^2.0",
|
||||
"psr/http-server-handler": "^1.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Http\\Server\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for HTTP server-side middleware",
|
||||
"keywords": [
|
||||
"http",
|
||||
"http-interop",
|
||||
"middleware",
|
||||
"psr",
|
||||
"psr-15",
|
||||
"psr-7",
|
||||
"request",
|
||||
"response"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/php-fig/http-server-middleware/issues",
|
||||
"source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2"
|
||||
},
|
||||
"time": "2023-04-11T06:14:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/link",
|
||||
"version": "2.0.1",
|
||||
@@ -5619,90 +4976,6 @@
|
||||
],
|
||||
"time": "2026-03-06T16:58:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/mcp-bundle",
|
||||
"version": "v0.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/mcp-bundle.git",
|
||||
"reference": "739ad154256402f5a0c4dbbc4c5b0f8797e6f8fc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/mcp-bundle/zipball/739ad154256402f5a0c4dbbc4c5b0f8797e6f8fc",
|
||||
"reference": "739ad154256402f5a0c4dbbc4c5b0f8797e6f8fc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"mcp/sdk": "^0.4",
|
||||
"php-http/discovery": "^1.20",
|
||||
"symfony/config": "^7.3|^8.0",
|
||||
"symfony/console": "^7.3|^8.0",
|
||||
"symfony/dependency-injection": "^7.3|^8.0",
|
||||
"symfony/framework-bundle": "^7.3|^8.0",
|
||||
"symfony/http-foundation": "^7.3|^8.0",
|
||||
"symfony/http-kernel": "^7.3|^8.0",
|
||||
"symfony/psr-http-message-bridge": "^7.3|^8.0",
|
||||
"symfony/routing": "^7.3|^8.0",
|
||||
"symfony/service-contracts": "^2.5|^3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpstan/phpstan-phpunit": "^2.0",
|
||||
"phpstan/phpstan-strict-rules": "^2.0",
|
||||
"phpunit/phpunit": "^11.5.53",
|
||||
"symfony/monolog-bundle": "^3.10 || ^4.0"
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"extra": {
|
||||
"thanks": {
|
||||
"url": "https://github.com/symfony/ai",
|
||||
"name": "symfony/ai"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\AI\\McpBundle\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Christopher Hertel",
|
||||
"email": "mail@christopher-hertel.de"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony integration bundle for Model Context Protocol (via official mcp/sdk)",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/mcp-bundle/tree/v0.6.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-04T16:39:24+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/password-hasher",
|
||||
"version": "v8.0.6",
|
||||
@@ -6358,93 +5631,6 @@
|
||||
],
|
||||
"time": "2026-03-04T15:54:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/psr-http-message-bridge",
|
||||
"version": "v8.0.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/psr-http-message-bridge.git",
|
||||
"reference": "d6edf266746dd0b8e81e754a79da77b08dc00531"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/d6edf266746dd0b8e81e754a79da77b08dc00531",
|
||||
"reference": "d6edf266746dd0b8e81e754a79da77b08dc00531",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.4",
|
||||
"psr/http-message": "^1.0|^2.0",
|
||||
"symfony/http-foundation": "^7.4|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"php-http/discovery": "<1.15"
|
||||
},
|
||||
"require-dev": {
|
||||
"nyholm/psr7": "^1.1",
|
||||
"php-http/discovery": "^1.15",
|
||||
"psr/log": "^1.1.4|^2|^3",
|
||||
"symfony/browser-kit": "^7.4|^8.0",
|
||||
"symfony/config": "^7.4|^8.0",
|
||||
"symfony/event-dispatcher": "^7.4|^8.0",
|
||||
"symfony/framework-bundle": "^7.4|^8.0",
|
||||
"symfony/http-kernel": "^7.4|^8.0",
|
||||
"symfony/runtime": "^7.4|^8.0"
|
||||
},
|
||||
"type": "symfony-bridge",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Bridge\\PsrHttpMessage\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "PSR HTTP message bridge",
|
||||
"homepage": "https://symfony.com",
|
||||
"keywords": [
|
||||
"http",
|
||||
"http-message",
|
||||
"psr-17",
|
||||
"psr-7"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-01-03T23:40:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/routing",
|
||||
"version": "v8.0.6",
|
||||
|
||||
@@ -8,7 +8,6 @@ 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;
|
||||
@@ -23,5 +22,4 @@ return [
|
||||
ApiPlatformBundle::class => ['all' => true],
|
||||
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||
McpBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
@@ -1,7 +1,4 @@
|
||||
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'
|
||||
@@ -28,12 +25,6 @@ security:
|
||||
password_path: password
|
||||
success_handler: lexik_jwt_authentication.handler.authentication_success
|
||||
failure_handler: lexik_jwt_authentication.handler.authentication_failure
|
||||
mcp:
|
||||
pattern: ^/_mcp
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
custom_authenticators:
|
||||
- App\Security\ApiTokenAuthenticator
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: true
|
||||
@@ -59,7 +50,6 @@ security:
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
# Version de l'application en public
|
||||
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
|
||||
when@test:
|
||||
|
||||
@@ -1610,37 +1610,6 @@ 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<array{ // Default: []
|
||||
* src?: scalar|Param|null,
|
||||
* mime_type?: scalar|Param|null, // Default: null
|
||||
* sizes?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
* 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<scalar|Param|null>,
|
||||
* exclude_dirs?: list<scalar|Param|null>,
|
||||
* },
|
||||
* 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,
|
||||
@@ -1653,7 +1622,6 @@ 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,
|
||||
@@ -1666,7 +1634,6 @@ 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,
|
||||
@@ -1680,7 +1647,6 @@ 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,
|
||||
@@ -1694,7 +1660,6 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* nelmio_cors?: NelmioCorsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* mcp?: McpConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
mcp:
|
||||
resource: .
|
||||
type: mcp
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.0'
|
||||
app.version: '0.1.1'
|
||||
|
||||
@@ -7,11 +7,6 @@ server {
|
||||
|
||||
client_max_body_size 55m;
|
||||
|
||||
location ^~ /_mcp {
|
||||
root /var/www/html/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /api/ {
|
||||
root /var/www/html/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,970 +0,0 @@
|
||||
# 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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use App\Repository\NotificationRepository;
|
||||
use App\State\NotificationProvider;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
provider: NotificationProvider::class,
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY')",
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY') and object.getUser() == user",
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Notification;
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Notification>
|
||||
*/
|
||||
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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\Notification;
|
||||
use App\Repository\NotificationRepository;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<Notification>
|
||||
*/
|
||||
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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\NotificationRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class NotificationUnreadCountController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationRepository $notificationRepository,
|
||||
) {}
|
||||
|
||||
#[Route('/api/notifications/unread-count', name: 'notification_unread_count', methods: ['GET'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$count = $this->notificationRepository->countUnreadByUser($user);
|
||||
|
||||
return new JsonResponse(['count' => $count]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Task 6: Create the MarkAllReadController
|
||||
|
||||
- [ ] **Create `src/Controller/MarkAllReadController.php`**:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\NotificationRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class MarkAllReadController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationRepository $notificationRepository,
|
||||
) {}
|
||||
|
||||
#[Route('/api/notifications/mark-all-read', name: 'notification_mark_all_read', methods: ['POST'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function __invoke(): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->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
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\ClientTicket;
|
||||
use App\Entity\Notification;
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final readonly class NotificationService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private UserRepository $userRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Notify all ROLE_ADMIN users that a new ticket was created.
|
||||
*/
|
||||
public function createForTicketCreated(ClientTicket $ticket): void
|
||||
{
|
||||
$admins = $this->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<Notification[]> {
|
||||
const data = await api.get<HydraCollection<Notification>>('/notifications')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function markAsRead(id: number): Promise<void> {
|
||||
await api.patch(`/notifications/${id}`, { isRead: true }, {
|
||||
toast: false,
|
||||
})
|
||||
}
|
||||
|
||||
async function markAllAsRead(): Promise<void> {
|
||||
await api.post('/notifications/mark-all-read', {}, {
|
||||
toast: false,
|
||||
})
|
||||
}
|
||||
|
||||
async function getUnreadCount(): Promise<number> {
|
||||
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<number>('notification-unread-count', () => 0)
|
||||
const notifications = useState<Notification[]>('notification-list', () => [])
|
||||
const isLoading = useState<boolean>('notification-loading', () => false)
|
||||
|
||||
const service = useNotificationService()
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function fetchUnreadCount(): Promise<void> {
|
||||
try {
|
||||
unreadCount.value = await service.getUnreadCount()
|
||||
} catch {
|
||||
// Silently ignore polling errors
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNotifications(): Promise<void> {
|
||||
isLoading.value = true
|
||||
try {
|
||||
notifications.value = await service.getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsRead(id: number): Promise<void> {
|
||||
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<void> {
|
||||
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
|
||||
<template>
|
||||
<div ref="bellRef" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<Icon name="mdi:bell-outline" size="24" />
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
>
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-neutral-200 bg-white shadow-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-neutral-800">
|
||||
{{ $t('notification.title') }}
|
||||
</h3>
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
type="button"
|
||||
class="text-xs text-primary-500 hover:text-primary-700 transition-colors"
|
||||
@click="handleMarkAllRead"
|
||||
>
|
||||
{{ $t('notification.markAllRead') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<Icon name="mdi:loading" size="24" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="notifications.length === 0" class="px-4 py-8 text-center text-sm text-neutral-500">
|
||||
{{ $t('notification.empty') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="notif in notifications"
|
||||
:key="notif.id"
|
||||
type="button"
|
||||
class="flex w-full gap-3 px-4 py-3 text-left transition-colors hover:bg-neutral-50"
|
||||
:class="{ 'bg-primary-50': !notif.isRead }"
|
||||
@click="handleClick(notif)"
|
||||
>
|
||||
<div
|
||||
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full"
|
||||
:class="notif.isRead ? 'bg-transparent' : 'bg-primary-500'"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-neutral-800 truncate">
|
||||
{{ notif.title }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-500 truncate">
|
||||
{{ notif.message }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-neutral-400">
|
||||
{{ formatRelativeDate(notif.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Notification } from '~/services/dto/notification'
|
||||
import { useNotifications } from '~/composables/useNotifications'
|
||||
|
||||
const {
|
||||
unreadCount,
|
||||
notifications,
|
||||
isLoading,
|
||||
fetchNotifications,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
} = useNotifications()
|
||||
|
||||
const bellRef = ref<HTMLElement>()
|
||||
const isOpen = ref(false)
|
||||
|
||||
function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
fetchNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(notif: Notification) {
|
||||
if (!notif.isRead) {
|
||||
markAsRead(notif.id)
|
||||
}
|
||||
|
||||
if (notif.relatedTicket) {
|
||||
const ticketId = notif.relatedTicket.split('/').pop()
|
||||
const auth = useAuthStore()
|
||||
const isClient = auth.user?.roles?.includes('ROLE_CLIENT')
|
||||
|
||||
if (isClient) {
|
||||
navigateTo(`/portal`)
|
||||
} else {
|
||||
navigateTo(`/admin?tab=tickets`)
|
||||
}
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
await markAllAsRead()
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function formatRelativeDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMin / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffMin < 1) return t('notification.timeAgo.now')
|
||||
if (diffMin < 60) return t('notification.timeAgo.minutes', { n: diffMin })
|
||||
if (diffHours < 24) return t('notification.timeAgo.hours', { n: diffHours })
|
||||
if (diffDays < 7) return t('notification.timeAgo.days', { n: diffDays })
|
||||
|
||||
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
function onClickOutside(event: MouseEvent) {
|
||||
if (!bellRef.value?.contains(event.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startPolling()
|
||||
document.addEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **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 `<div class="ml-auto flex gap-4 ...">` block (line 10):
|
||||
|
||||
Replace:
|
||||
```vue
|
||||
<div class="ml-auto flex gap-4 text-xl text-white sm:gap-12">
|
||||
<div class="group relative flex gap-2 sm:gap-4">
|
||||
```
|
||||
|
||||
With:
|
||||
```vue
|
||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||
<NotificationBell />
|
||||
<div class="group relative flex gap-2 sm:gap-4">
|
||||
```
|
||||
|
||||
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"
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,495 +0,0 @@
|
||||
# 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).
|
||||
|
||||
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://<server-ip>:8082/_mcp`), secured by API token
|
||||
|
||||
Future: Cloudflare Tunnel for internet-facing access (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
|
||||
│ │ └── UpdateProjectTool.php
|
||||
│ ├── Task/
|
||||
│ │ ├── ListTasksTool.php
|
||||
│ │ ├── GetTaskTool.php
|
||||
│ │ ├── CreateTaskTool.php
|
||||
│ │ ├── UpdateTaskTool.php
|
||||
│ │ └── DeleteTaskTool.php
|
||||
│ ├── TaskMeta/
|
||||
│ │ ├── ListStatusesTool.php
|
||||
│ │ ├── ListPrioritiesTool.php
|
||||
│ │ ├── ListEffortsTool.php
|
||||
│ │ ├── ListTagsTool.php
|
||||
│ │ ├── ListGroupsTool.php
|
||||
│ │ ├── CreateGroupTool.php
|
||||
│ │ └── UpdateGroupTool.php
|
||||
│ ├── TimeEntry/
|
||||
│ │ ├── ListTimeEntriesTool.php
|
||||
│ │ ├── CreateTimeEntryTool.php
|
||||
│ │ ├── UpdateTimeEntryTool.php
|
||||
│ │ └── DeleteTimeEntryTool.php
|
||||
│ └── Reference/
|
||||
│ ├── ListUsersTool.php
|
||||
│ └── ListClientsTool.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.
|
||||
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
|
||||
```
|
||||
|
||||
### 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
|
||||
{
|
||||
"mcpServers": {
|
||||
"lesstime": {
|
||||
"command": "docker",
|
||||
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"],
|
||||
"cwd": "/home/r-dev/Lesstime"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Option B — Network (HTTP, another machine on LAN):**
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"lesstime": {
|
||||
"type": "url",
|
||||
"url": "http://192.168.x.x:8082/_mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <api-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Security Model
|
||||
|
||||
**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 <token>` 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 <username>` to generate/regenerate tokens
|
||||
5. **Fixtures**: Add an API token to the admin fixture user for dev/testing
|
||||
|
||||
## 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`
|
||||
- **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`
|
||||
|
||||
#### `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. Returns max 100 results, use filters to narrow down.
|
||||
- **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)
|
||||
- `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, 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, createdAt, uploadedBy: { id, username } }], 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, 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)
|
||||
- **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
|
||||
|
||||
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)
|
||||
- **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. 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
|
||||
|
||||
#### `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`
|
||||
- **Description**: List time entries with filters
|
||||
- **Parameters**:
|
||||
- `userId` (int, optional)
|
||||
- `projectId` (int, optional)
|
||||
- `taskId` (int, optional)
|
||||
- `startDate` (string, optional, format: YYYY-MM-DD)
|
||||
- `endDate` (string, optional, format: YYYY-MM-DD)
|
||||
- `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**:
|
||||
- `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)
|
||||
- **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, 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`
|
||||
- **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)
|
||||
- **Duration**: Computed field (minutes), `null` for active timers
|
||||
|
||||
Example tool implementation pattern:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Task;
|
||||
|
||||
use App\Repository\TaskRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
class ListTasksTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskRepository $taskRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, priority, group, tags, and archive state')]
|
||||
public function __invoke(
|
||||
?int $projectId = null,
|
||||
?int $statusId = null,
|
||||
?int $assigneeId = null,
|
||||
?int $priorityId = null,
|
||||
?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')
|
||||
->setMaxResults($limit);
|
||||
|
||||
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(),
|
||||
'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)));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Installation Steps
|
||||
|
||||
1. `composer require symfony/mcp-bundle` (inside Docker container)
|
||||
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 <token>" http://localhost:8082/_mcp`
|
||||
12. Configure Claude Code settings (STDIO local or HTTP network)
|
||||
|
||||
## Future
|
||||
|
||||
When ready for internet-facing access:
|
||||
|
||||
1. Set up Cloudflare Tunnel for external access
|
||||
2. Configure Claude Web / ChatGPT / Codex with the tunnel URL + token
|
||||
@@ -1,379 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<MalioSelect
|
||||
v-model="filterProjectId"
|
||||
:options="projectOptions"
|
||||
label="Projet"
|
||||
:empty-option-label="$t('clientTicket.allProjects')"
|
||||
min-width="!w-40"
|
||||
text-field="text-sm"
|
||||
text-value="text-sm"
|
||||
/>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">Statut</label>
|
||||
<select
|
||||
v-model="filterStatus"
|
||||
class="rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option :value="null">{{ $t('clientTicket.allStatuses') }}</option>
|
||||
<option value="new">{{ $t('clientTicket.status.new') }}</option>
|
||||
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
|
||||
<option value="done">{{ $t('clientTicket.status.done') }}</option>
|
||||
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket list -->
|
||||
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-200 text-xs font-semibold uppercase text-neutral-500">
|
||||
<th class="px-3 py-3">#</th>
|
||||
<th class="px-3 py-3">Type</th>
|
||||
<th class="px-3 py-3">{{ $t('clientTicket.title') }}</th>
|
||||
<th class="px-3 py-3">Statut</th>
|
||||
<th class="px-3 py-3">Projet</th>
|
||||
<th class="px-3 py-3">{{ $t('clientTicket.submittedBy') }}</th>
|
||||
<th class="px-3 py-3">{{ $t('clientTicket.createdAt') }}</th>
|
||||
<th class="px-3 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="ticket in filteredTickets"
|
||||
:key="ticket.id"
|
||||
class="cursor-pointer border-b border-neutral-100 transition-colors hover:bg-neutral-50"
|
||||
@click="openDetail(ticket)"
|
||||
>
|
||||
<td class="px-3 py-3 font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 font-medium text-neutral-900">{{ ticket.title }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-neutral-600">{{ getProjectName(ticket.project) }}</td>
|
||||
<td class="px-3 py-3 text-neutral-600">{{ getSubmitterName(ticket.submittedBy) }}</td>
|
||||
<td class="px-3 py-3 text-neutral-400">{{ formatDate(ticket.createdAt) }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
||||
:title="$t('clientTicket.changeStatus')"
|
||||
@click.stop="openStatusChange(ticket)"
|
||||
>
|
||||
<Icon name="mdi:swap-horizontal" size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500"
|
||||
@click.stop="openDeleteConfirm(ticket)"
|
||||
>
|
||||
<Icon name="mdi:delete-outline" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Status change modal -->
|
||||
<Teleport v-if="statusModalOpen" to="body">
|
||||
<Transition name="status-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="statusModalOpen = false"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
|
||||
CT-{{ String(statusTarget.number).padStart(3, '0') }} — {{ statusTarget.title }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
|
||||
<select
|
||||
v-model="newStatus"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option :value="null" disabled>—</option>
|
||||
<option
|
||||
v-for="s in availableStatusTransitions"
|
||||
:key="s.value"
|
||||
:value="s.value"
|
||||
>
|
||||
{{ s.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="newStatus === 'rejected'" class="mt-4">
|
||||
<MalioInputTextArea
|
||||
v-model="statusComment"
|
||||
:label="$t('clientTicket.statusComment')"
|
||||
:size="3"
|
||||
/>
|
||||
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
|
||||
{{ $t('clientTicket.rejectionRequired') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
@click="statusModalOpen = false"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isUpdatingStatus"
|
||||
@click="confirmStatusChange"
|
||||
>
|
||||
Confirmer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Delete confirm modal -->
|
||||
<Teleport v-if="deleteModalOpen" to="body">
|
||||
<Transition name="status-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="deleteModalOpen = false"
|
||||
/>
|
||||
<div class="relative z-10 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.confirmDelete') }}</h3>
|
||||
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.confirmDeleteMessage') }}</p>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
@click="deleteModalOpen = false"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-red-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isDeleting"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Ticket detail modal (read-only) -->
|
||||
<ClientTicketDetailModal
|
||||
v-model="detailOpen"
|
||||
:ticket="detailTicket"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const { t } = useI18n()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
const userService = useUserService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
|
||||
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
// Filters
|
||||
const filterProjectId = ref<number | null>(null)
|
||||
const filterStatus = ref<string | null>(null)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const filteredTickets = computed(() => {
|
||||
let result = tickets.value
|
||||
if (filterProjectId.value) {
|
||||
result = result.filter(t => t.project === `/api/projects/${filterProjectId.value}`)
|
||||
}
|
||||
if (filterStatus.value) {
|
||||
result = result.filter(t => t.status === filterStatus.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Status change modal
|
||||
const statusModalOpen = ref(false)
|
||||
const statusTarget = ref<ClientTicket | null>(null)
|
||||
const newStatus = ref<string | null>(null)
|
||||
const statusComment = ref('')
|
||||
const rejectionError = ref(false)
|
||||
const isUpdatingStatus = ref(false)
|
||||
|
||||
// Delete modal
|
||||
const deleteModalOpen = ref(false)
|
||||
const deleteTarget = ref<ClientTicket | null>(null)
|
||||
const isDeleting = ref(false)
|
||||
|
||||
// Detail modal
|
||||
const detailOpen = ref(false)
|
||||
const detailTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
const availableStatusTransitions = computed(() => {
|
||||
if (!statusTarget.value) return []
|
||||
const current = statusTarget.value.status
|
||||
const allStatuses: { label: string; value: ClientTicketStatus }[] = [
|
||||
{ label: t('clientTicket.status.new'), value: 'new' },
|
||||
{ label: t('clientTicket.status.in_progress'), value: 'in_progress' },
|
||||
{ label: t('clientTicket.status.done'), value: 'done' },
|
||||
{ label: t('clientTicket.status.rejected'), value: 'rejected' },
|
||||
]
|
||||
// Filter out forbidden transitions
|
||||
return allStatuses.filter(s => {
|
||||
if (s.value === current) return false
|
||||
if ((current === 'done' || current === 'rejected') && s.value === 'new') return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
function getProjectName(iri: string): string {
|
||||
const match = iri.match(/\/api\/projects\/(\d+)/)
|
||||
if (!match) return ''
|
||||
const id = Number(match[1])
|
||||
return projects.value.find(p => p.id === id)?.name ?? ''
|
||||
}
|
||||
|
||||
function getSubmitterName(iri: string | null): string {
|
||||
if (!iri) return '-'
|
||||
const match = iri.match(/\/api\/users\/(\d+)/)
|
||||
if (!match) return ''
|
||||
const id = Number(match[1])
|
||||
return users.value.find(u => u.id === id)?.username ?? ''
|
||||
}
|
||||
|
||||
function openDetail(ticket: ClientTicket) {
|
||||
detailTicket.value = ticket
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
function openStatusChange(ticket: ClientTicket) {
|
||||
statusTarget.value = ticket
|
||||
newStatus.value = null
|
||||
statusComment.value = ''
|
||||
rejectionError.value = false
|
||||
statusModalOpen.value = true
|
||||
}
|
||||
|
||||
function openDeleteConfirm(ticket: ClientTicket) {
|
||||
deleteTarget.value = ticket
|
||||
deleteModalOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmStatusChange() {
|
||||
if (!statusTarget.value || !newStatus.value) return
|
||||
|
||||
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
|
||||
rejectionError.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isUpdatingStatus.value = true
|
||||
try {
|
||||
await clientTicketService.updateStatus(statusTarget.value.id, {
|
||||
status: newStatus.value as ClientTicketStatus,
|
||||
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
|
||||
})
|
||||
statusModalOpen.value = false
|
||||
await loadTickets()
|
||||
} finally {
|
||||
isUpdatingStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget.value) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await clientTicketService.remove(deleteTarget.value.id)
|
||||
deleteModalOpen.value = false
|
||||
await loadTickets()
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
tickets.value = await clientTicketService.getAll()
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const [ticketsResult, projectsResult, usersResult] = await Promise.all([
|
||||
clientTicketService.getAll(),
|
||||
projectService.getAll(),
|
||||
userService.getAll(),
|
||||
])
|
||||
tickets.value = ticketsResult
|
||||
projects.value = projectsResult
|
||||
users.value = usersResult
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-modal-enter-active,
|
||||
.status-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.status-modal-enter-from,
|
||||
.status-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,209 +0,0 @@
|
||||
<template>
|
||||
<Teleport v-if="isOpen" to="body">
|
||||
<Transition name="ticket-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||
style="max-height: min(90vh, 900px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-b border-neutral-100 bg-neutral-50/80 px-4 py-4 sm:px-8 sm:py-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
v-if="ticket"
|
||||
class="rounded-md bg-primary-500 px-2.5 py-1 text-xs font-bold tracking-wide text-white"
|
||||
>
|
||||
CT-{{ String(ticket.number).padStart(3, '0') }}
|
||||
</span>
|
||||
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
|
||||
{{ $t('portal.ticketDetail') }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div v-if="ticket" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||
<!-- Title -->
|
||||
<h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-3 py-1 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-4">
|
||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
||||
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- URL (if bug) -->
|
||||
<div v-if="ticket.url" class="mt-4">
|
||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.url') }}</p>
|
||||
<a
|
||||
:href="ticket.url"
|
||||
target="_blank"
|
||||
class="mt-1 text-sm text-primary-500 underline hover:text-primary-600"
|
||||
>
|
||||
{{ ticket.url }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Status comment -->
|
||||
<div v-if="ticket.statusComment" class="mt-4">
|
||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.statusComment') }}</p>
|
||||
<p class="mt-1 whitespace-pre-wrap rounded-lg bg-neutral-50 p-3 text-sm text-neutral-600">{{ ticket.statusComment }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Documents -->
|
||||
<TaskDocumentList
|
||||
v-if="localDocuments.length"
|
||||
:documents="localDocuments"
|
||||
:is-admin="false"
|
||||
@preview="openPreview"
|
||||
/>
|
||||
|
||||
<!-- Document preview -->
|
||||
<TaskDocumentPreview
|
||||
:document="previewDoc"
|
||||
:has-prev="previewIndex > 0"
|
||||
:has-next="previewIndex < localDocuments.length - 1"
|
||||
@close="previewDoc = null"
|
||||
@prev="prevPreview"
|
||||
@next="nextPreview"
|
||||
/>
|
||||
|
||||
<!-- Upload zone -->
|
||||
<TaskDocumentUpload
|
||||
v-if="ticket"
|
||||
:client-ticket-id="ticket.id"
|
||||
@uploaded="refreshDocuments"
|
||||
/>
|
||||
|
||||
<!-- Date -->
|
||||
<p class="mt-6 text-xs text-neutral-400">
|
||||
{{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||
import type { TaskDocument } from '~/services/dto/task-document'
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
ticket: ClientTicket | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
const { getByTicket } = useTaskDocumentService()
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
|
||||
|
||||
async function refreshDocuments() {
|
||||
if (!props.ticket) return
|
||||
localDocuments.value = await getByTicket(props.ticket.id)
|
||||
}
|
||||
|
||||
// Document list (local copy to allow refresh)
|
||||
const localDocuments = ref<TaskDocument[]>([])
|
||||
|
||||
watch(() => props.ticket?.documents, (docs) => {
|
||||
localDocuments.value = docs ? [...docs] : []
|
||||
}, { immediate: true })
|
||||
|
||||
// Document preview
|
||||
const previewDoc = ref<TaskDocument | null>(null)
|
||||
|
||||
const previewIndex = computed(() => {
|
||||
if (!previewDoc.value) return -1
|
||||
return localDocuments.value.findIndex(d => d.id === previewDoc.value!.id)
|
||||
})
|
||||
|
||||
function openPreview(doc: TaskDocument) {
|
||||
previewDoc.value = doc
|
||||
}
|
||||
|
||||
function prevPreview() {
|
||||
if (previewIndex.value > 0) {
|
||||
previewDoc.value = localDocuments.value[previewIndex.value - 1]
|
||||
}
|
||||
}
|
||||
|
||||
function nextPreview() {
|
||||
if (previewIndex.value < localDocuments.value.length - 1) {
|
||||
previewDoc.value = localDocuments.value[previewIndex.value + 1]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticket-modal-enter-active,
|
||||
.ticket-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.ticket-modal-enter-active > div:last-child,
|
||||
.ticket-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.ticket-modal-enter-from,
|
||||
.ticket-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ticket-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ticket-modal-leave-to > div:last-child {
|
||||
transform: scale(0.97);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,171 +0,0 @@
|
||||
<template>
|
||||
<div ref="bellRef" class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="relative rounded-md p-2 text-white hover:bg-primary-600 transition-colors"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<Icon name="mdi:bell-outline" size="24" />
|
||||
<span
|
||||
v-if="unreadCount > 0"
|
||||
class="absolute -right-0.5 -top-0.5 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
>
|
||||
{{ unreadCount > 99 ? '99+' : unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute right-0 top-full z-50 mt-2 w-80 rounded-md border border-neutral-200 bg-white shadow-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-neutral-800">
|
||||
{{ $t('notification.title') }}
|
||||
</h3>
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
type="button"
|
||||
class="text-xs text-primary-500 hover:text-primary-700 transition-colors"
|
||||
@click="handleMarkAllRead"
|
||||
>
|
||||
{{ $t('notification.markAllRead') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-8">
|
||||
<Icon name="mdi:loading" size="24" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="notifications.length === 0" class="px-4 py-8 text-center text-sm text-neutral-500">
|
||||
{{ $t('notification.empty') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="notif in notifications"
|
||||
:key="notif.id"
|
||||
type="button"
|
||||
class="flex w-full gap-3 px-4 py-3 text-left transition-colors hover:bg-neutral-50"
|
||||
:class="{ 'bg-primary-50': !notif.isRead }"
|
||||
@click="handleClick(notif)"
|
||||
>
|
||||
<div
|
||||
class="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full"
|
||||
:class="notif.isRead ? 'bg-transparent' : 'bg-primary-500'"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-neutral-800 truncate">
|
||||
{{ notif.title }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-neutral-500 truncate">
|
||||
{{ notif.message }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-neutral-400">
|
||||
{{ formatRelativeDate(notif.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Notification } from '~/services/dto/notification'
|
||||
import { useNotifications } from '~/composables/useNotifications'
|
||||
|
||||
const {
|
||||
unreadCount,
|
||||
notifications,
|
||||
isLoading,
|
||||
fetchNotifications,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
} = useNotifications()
|
||||
|
||||
const bellRef = ref<HTMLElement>()
|
||||
const isOpen = ref(false)
|
||||
|
||||
function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
fetchNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(notif: Notification) {
|
||||
if (!notif.isRead) {
|
||||
markAsRead(notif.id)
|
||||
}
|
||||
|
||||
if (notif.relatedTicket) {
|
||||
const auth = useAuthStore()
|
||||
const isClient = auth.user?.roles?.includes('ROLE_CLIENT')
|
||||
|
||||
if (isClient) {
|
||||
navigateTo(`/portal`)
|
||||
} else {
|
||||
navigateTo(`/admin?tab=tickets`)
|
||||
}
|
||||
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
await markAllAsRead()
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function formatRelativeDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMin / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffMin < 1) return t('notification.timeAgo.now')
|
||||
if (diffMin < 60) return t('notification.timeAgo.minutes', { n: diffMin })
|
||||
if (diffHours < 24) return t('notification.timeAgo.hours', { n: diffHours })
|
||||
if (diffDays < 7) return t('notification.timeAgo.days', { n: diffDays })
|
||||
|
||||
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
function onClickOutside(event: MouseEvent) {
|
||||
if (!bellRef.value?.contains(event.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startPolling()
|
||||
document.addEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
@@ -8,15 +8,7 @@
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-1">
|
||||
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
|
||||
<Icon
|
||||
v-if="task.clientTicket"
|
||||
name="heroicons:user-circle"
|
||||
class="h-4 w-4 text-blue-400"
|
||||
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="task.project && task.number" class="text-xs font-medium text-neutral-400">{{ task.project.code }}{{ task.number }}</span>
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -49,15 +49,14 @@
|
||||
import { useTaskDocumentService } from '~/services/task-documents'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId?: number
|
||||
clientTicketId?: number
|
||||
taskId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploaded: []
|
||||
}>()
|
||||
|
||||
const { upload: uploadFile, uploadForTicket } = useTaskDocumentService()
|
||||
const { upload: uploadFile } = useTaskDocumentService()
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -110,11 +109,7 @@ async function processFiles(files: File[]) {
|
||||
uploads.value.push(state)
|
||||
|
||||
try {
|
||||
if (props.clientTicketId) {
|
||||
await uploadForTicket(props.clientTicketId, file)
|
||||
} else if (props.taskId) {
|
||||
await uploadFile(props.taskId, file)
|
||||
}
|
||||
await uploadFile(props.taskId, file)
|
||||
state.uploading = false
|
||||
state.progress = 100
|
||||
} catch {
|
||||
|
||||
@@ -35,23 +35,6 @@
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Client ticket link -->
|
||||
<div
|
||||
v-if="isEditing && task?.clientTicket"
|
||||
class="mt-2 flex items-center gap-2 rounded-lg bg-blue-50 px-3 py-2"
|
||||
>
|
||||
<Icon name="heroicons:user-circle" class="h-5 w-5 text-blue-500" />
|
||||
<span class="text-sm font-medium text-blue-700">
|
||||
{{ $t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') }) }}
|
||||
</span>
|
||||
<span
|
||||
class="ml-auto rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
:class="ticketStatusClass(task.clientTicket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${task.clientTicket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
@@ -405,16 +388,6 @@ 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<TaskDocument[]>([])
|
||||
const documents = computed(() => localDocuments.value)
|
||||
const previewDoc = ref<TaskDocument | null>(null)
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
>
|
||||
<Icon name="mdi:menu" size="24" />
|
||||
</button>
|
||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||
<NotificationBell />
|
||||
<div class="ml-auto flex gap-4 text-xl text-white sm:gap-12">
|
||||
<div class="group relative flex gap-2 sm:gap-4">
|
||||
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
|
||||
<p class="hidden self-center cursor-pointer sm:block">{{ user?.username }}</p>
|
||||
|
||||
@@ -36,39 +36,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<MalioSelect
|
||||
v-model="form.clientId"
|
||||
label="Client"
|
||||
:options="clientOptions"
|
||||
placeholder="Aucun client"
|
||||
class="w-full"
|
||||
@update:model-value="onClientChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="form.clientId !== null" class="mt-2">
|
||||
<label class="text-sm font-semibold text-neutral-700">Projets autorisés</label>
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<label
|
||||
v-for="project in filteredProjects"
|
||||
:key="project.id"
|
||||
class="flex items-center gap-2 text-sm text-neutral-700"
|
||||
>
|
||||
<input
|
||||
v-model="form.allowedProjectIds"
|
||||
type="checkbox"
|
||||
:value="project.id"
|
||||
class="rounded border-neutral-300"
|
||||
/>
|
||||
{{ project.name }}
|
||||
</label>
|
||||
<span v-if="filteredProjects.length === 0" class="text-sm text-neutral-400">
|
||||
Aucun projet pour ce client.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -85,10 +52,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { UserData, UserWrite } from '~/services/dto/user-data'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useClientService } from '~/services/clients'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import type { Client } from '~/services/dto/client'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -105,32 +68,15 @@ const isOpen = computed({
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_CLIENT']
|
||||
const availableRoles = ['ROLE_ADMIN', 'ROLE_USER']
|
||||
|
||||
const isEditing = computed(() => !!props.item)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const clients = ref<Client[]>([])
|
||||
const allProjects = ref<Project[]>([])
|
||||
|
||||
const clientOptions = computed(() => [
|
||||
{ label: 'Aucun client', value: null as number | null },
|
||||
...clients.value.map((c) => ({ label: c.name, value: c.id as number | null })),
|
||||
])
|
||||
|
||||
const filteredProjects = computed(() => {
|
||||
if (form.clientId === null) return []
|
||||
return allProjects.value.filter(
|
||||
(p) => p.client !== null && p.client.id === form.clientId,
|
||||
)
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
roles: [] as string[],
|
||||
clientId: null as number | null,
|
||||
allowedProjectIds: [] as number[],
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
@@ -138,38 +84,19 @@ const touched = reactive({
|
||||
password: false,
|
||||
})
|
||||
|
||||
function onClientChange(value: number | null) {
|
||||
form.clientId = value
|
||||
form.allowedProjectIds = []
|
||||
if (value !== null && !form.roles.includes('ROLE_CLIENT')) {
|
||||
form.roles = [...form.roles.filter((r) => r !== 'ROLE_USER'), 'ROLE_CLIENT']
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.item) {
|
||||
form.username = props.item.username ?? ''
|
||||
form.password = ''
|
||||
form.roles = [...props.item.roles]
|
||||
form.clientId = props.item.client?.id ?? null
|
||||
form.allowedProjectIds = props.item.allowedProjects?.map((p) => p.id) ?? []
|
||||
} else {
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.roles = ['ROLE_USER']
|
||||
form.clientId = null
|
||||
form.allowedProjectIds = []
|
||||
}
|
||||
touched.username = false
|
||||
touched.password = false
|
||||
|
||||
const [loadedClients, loadedProjects] = await Promise.all([
|
||||
useClientService().getAll(),
|
||||
useProjectService().getAll({ archived: false }),
|
||||
])
|
||||
clients.value = loadedClients
|
||||
allProjects.value = loadedProjects
|
||||
}
|
||||
})
|
||||
|
||||
@@ -186,8 +113,6 @@ async function handleSubmit() {
|
||||
const payload: UserWrite = {
|
||||
username: form.username.trim(),
|
||||
roles: form.roles,
|
||||
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
|
||||
allowedProjects: form.allowedProjectIds.map((id) => `/api/projects/${id}`),
|
||||
}
|
||||
if (form.password) {
|
||||
payload.password = form.password
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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<number>('notification-unread-count', () => 0)
|
||||
const notifications = useState<Notification[]>('notification-list', () => [])
|
||||
const isLoading = useState<boolean>('notification-loading', () => false)
|
||||
|
||||
const service = useNotificationService()
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function fetchUnreadCount(): Promise<void> {
|
||||
try {
|
||||
unreadCount.value = await service.getUnreadCount()
|
||||
} catch {
|
||||
// Silently ignore polling errors
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNotifications(): Promise<void> {
|
||||
isLoading.value = true
|
||||
try {
|
||||
notifications.value = await service.getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsRead(id: number): Promise<void> {
|
||||
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<void> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -213,75 +213,6 @@
|
||||
"error": "Erreur de connexion à Gitea.",
|
||||
"notConfigured": "Gitea non configuré pour ce projet."
|
||||
},
|
||||
"portal": {
|
||||
"title": "Portail client",
|
||||
"projects": "Vos projets",
|
||||
"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",
|
||||
"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 de la page",
|
||||
"urlPlaceholder": "https://example.com/page-concernee",
|
||||
"type": "Type",
|
||||
"project": "Projet"
|
||||
},
|
||||
"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}",
|
||||
"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 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": {
|
||||
"title": "Configuration BookStack",
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="h-screen overflow-hidden">
|
||||
<div class="flex h-full">
|
||||
<!-- Mobile sidebar overlay -->
|
||||
<Transition name="sidebar-overlay">
|
||||
<div
|
||||
v-if="ui.sidebarOpen"
|
||||
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<aside
|
||||
class="fixed inset-y-0 left-0 z-50 flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:z-auto lg:translate-x-0"
|
||||
:class="ui.sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<img src="/malio.png" alt="Logo" class="w-auto" />
|
||||
<button
|
||||
class="mr-2 rounded-md p-2 text-neutral-500 hover:bg-neutral-200 hover:text-neutral-700 transition-colors lg:hidden"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
>
|
||||
<Icon name="mdi:close" size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<nav class="flex-1 px-4 pb-6">
|
||||
<SidebarLink
|
||||
to="/portal"
|
||||
icon="mdi:folder-outline"
|
||||
label="Mes projets"
|
||||
:collapsed="false"
|
||||
class="border-t border-secondary-500 pt-6"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<SidebarLink
|
||||
v-if="isAdmin"
|
||||
to="/"
|
||||
icon="mdi:shield-crown-outline"
|
||||
label="Administration"
|
||||
:collapsed="false"
|
||||
class="mt-2"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col gap-2 items-center p-4">
|
||||
<p class="font-bold">v {{ version }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="h-full flex-1 flex flex-col min-h-0">
|
||||
<AppTopNav :user="auth.user" />
|
||||
<main class="flex flex-1 flex-col overflow-y-auto bg-white px-4 pb-24 sm:px-8 lg:px-16">
|
||||
<div aria-hidden="true" class="pointer-events-none sticky top-0 z-30 h-8 flex-shrink-0 bg-white sm:h-12" />
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAppVersion } from '~/composables/useAppVersion'
|
||||
|
||||
const auth = useAuthStore()
|
||||
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()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-overlay-enter-active,
|
||||
.sidebar-overlay-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.sidebar-overlay-enter-from,
|
||||
.sidebar-overlay-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,26 +1,16 @@
|
||||
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) {
|
||||
const isClientOnly = auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||
return navigateTo(isClientOnly ? '/portal' : '/')
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return navigateTo('/portal')
|
||||
}
|
||||
}
|
||||
if (isLogin && auth.isAuthenticated) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
<AdminPriorityTab v-if="activeTab === 'priorities'" />
|
||||
<AdminTagTab v-if="activeTab === 'tags'" />
|
||||
<AdminUserTab v-if="activeTab === 'users'" />
|
||||
<AdminClientTicketTab v-if="activeTab === 'client-tickets'" />
|
||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||
</div>
|
||||
@@ -44,7 +43,6 @@ 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
|
||||
|
||||
@@ -63,8 +63,7 @@ const handleSubmit = async () => {
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
|
||||
const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false
|
||||
await router.push(isClient ? '/portal' : '/')
|
||||
await router.push('/')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
|
||||
@@ -411,20 +411,12 @@ onMounted(() => {
|
||||
>
|
||||
<Icon :name="isTimerOnTask(task) ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'" size="20" />
|
||||
</button>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon
|
||||
v-if="task.clientTicket"
|
||||
name="heroicons:user-circle"
|
||||
class="h-4 w-4 text-blue-400"
|
||||
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
||||
/>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-sm font-medium text-primary-500"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="task.project && task.number"
|
||||
class="text-sm font-medium text-primary-500"
|
||||
>
|
||||
{{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.projects') }}</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="projects.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('portal.noProjects') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<NuxtLink
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
:to="`/portal/projects/${project.id}`"
|
||||
class="rounded-lg border border-neutral-200 bg-white p-5 shadow-sm transition hover:shadow-md"
|
||||
>
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ project.name }}</h3>
|
||||
<p class="mt-2 text-sm text-neutral-500">
|
||||
{{ ticketCountByProject[project.id] ?? 0 }} {{ $t('portal.openTickets') }}
|
||||
</p>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'portal',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
useHead({ title: t('portal.title') })
|
||||
|
||||
const auth = useAuthStore()
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
const ticketCountByProject = computed(() => {
|
||||
const counts: Record<number, number> = {}
|
||||
for (const ticket of tickets.value) {
|
||||
if (ticket.status === 'new' || ticket.status === 'in_progress') {
|
||||
// Extract project ID from IRI
|
||||
const match = ticket.project.match(/\/api\/projects\/(\d+)/)
|
||||
if (match) {
|
||||
const projectId = Number(match[1])
|
||||
counts[projectId] = (counts[projectId] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (auth.user?.roles?.includes('ROLE_ADMIN')) {
|
||||
// Admin sees all projects
|
||||
const allProjects = await projectService.getAll({ archived: false })
|
||||
projects.value = allProjects
|
||||
} else {
|
||||
// Client sees allowed projects
|
||||
projects.value = auth.user?.allowedProjects ?? []
|
||||
}
|
||||
tickets.value = await clientTicketService.getAll()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -1,123 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<NuxtLink
|
||||
to="/portal"
|
||||
class="text-sm text-neutral-400 hover:text-primary-500"
|
||||
>
|
||||
{{ $t('portal.backToProject') }}
|
||||
</NuxtLink>
|
||||
<h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ projectName }}</h1>
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-if="isClient"
|
||||
:to="`/portal/projects/${projectId}/new-ticket`"
|
||||
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
|
||||
>
|
||||
<span class="hidden sm:inline">+ {{ $t('portal.newTicket') }}</span>
|
||||
<span class="sm:hidden">+ Ticket</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="tickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
||||
{{ $t('clientTicket.noTickets') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 space-y-3">
|
||||
<div
|
||||
v-for="ticket in tickets"
|
||||
:key="ticket.id"
|
||||
class="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-neutral-200 bg-white p-4 shadow-sm transition hover:shadow-md"
|
||||
@click="openDetail(ticket)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:class="typeBadgeClass(ticket.type)"
|
||||
>
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="mt-1 text-sm font-semibold text-neutral-900">{{ ticket.title }}</h4>
|
||||
<p class="mt-1 text-xs text-neutral-400">
|
||||
{{ formatDate(ticket.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 rounded-full px-3 py-1 text-xs font-semibold"
|
||||
:class="statusBadgeClass(ticket.status)"
|
||||
>
|
||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ticket detail modal -->
|
||||
<ClientTicketDetailModal
|
||||
v-model="detailOpen"
|
||||
:ticket="selectedTicket"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'portal',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: t('portal.title') })
|
||||
|
||||
const clientTicketService = useClientTicketService()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const isLoading = ref(true)
|
||||
const detailOpen = ref(false)
|
||||
const selectedTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
const projectName = computed(() => {
|
||||
const me = auth.user as any
|
||||
if (me?.allowedProjects) {
|
||||
const project = me.allowedProjects.find((p: any) => p.id === projectId.value)
|
||||
return project?.name ?? ''
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') ?? false)
|
||||
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
|
||||
|
||||
function openDetail(ticket: ClientTicket) {
|
||||
selectedTicket.value = ticket
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTickets()
|
||||
})
|
||||
</script>
|
||||
@@ -1,134 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
||||
<NuxtLink
|
||||
:to="`/portal/projects/${projectId}`"
|
||||
class="text-sm text-neutral-400 hover:text-primary-500"
|
||||
>
|
||||
{{ $t('portal.backToProject') }}
|
||||
</NuxtLink>
|
||||
<h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('portal.newTicket') }}</h1>
|
||||
</div>
|
||||
|
||||
<form class="mt-4 max-w-2xl" @submit.prevent="handleSubmit">
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">{{ $t('clientTicket.selectType') }}</label>
|
||||
<select
|
||||
v-model="form.type"
|
||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="bug">{{ $t('clientTicket.type.bug') }}</option>
|
||||
<option value="improvement">{{ $t('clientTicket.type.improvement') }}</option>
|
||||
<option value="other">{{ $t('clientTicket.type.other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="mt-4">
|
||||
<MalioInputText
|
||||
v-model="form.title"
|
||||
:label="$t('clientTicket.title')"
|
||||
input-class="w-full"
|
||||
:error="touched.title && !form.title.trim() ? $t('clientTicket.title') + ' requis' : ''"
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mt-4">
|
||||
<MalioInputTextArea
|
||||
v-model="form.description"
|
||||
:label="$t('clientTicket.description')"
|
||||
:size="5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- URL (only for bug type) -->
|
||||
<div v-if="form.type === 'bug'" class="mt-4">
|
||||
<MalioInputText
|
||||
v-model="form.url"
|
||||
:label="$t('clientTicket.url')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Document upload (only after ticket is created) -->
|
||||
<div class="mt-4 rounded-lg border border-dashed border-neutral-300 p-4">
|
||||
<p class="text-sm text-neutral-500">
|
||||
<Icon name="heroicons:information-circle" class="mr-1 inline h-4 w-4" />
|
||||
Les documents pourront être ajoutés après la soumission du ticket.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<NuxtLink
|
||||
:to="`/portal/projects/${projectId}`"
|
||||
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
{{ $t('portal.submitTicket') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicketType } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'portal',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const projectId = computed(() => Number(route.params.id))
|
||||
|
||||
useHead({ title: t('portal.newTicket') })
|
||||
|
||||
const clientTicketService = useClientTicketService()
|
||||
|
||||
const form = reactive({
|
||||
type: 'bug' as ClientTicketType | string,
|
||||
title: '',
|
||||
description: '',
|
||||
url: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
title: false,
|
||||
})
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.title = true
|
||||
if (!form.title.trim()) return
|
||||
if (!form.description.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await clientTicketService.create({
|
||||
type: form.type as ClientTicketType,
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
url: form.type === 'bug' && form.url.trim() ? form.url.trim() : null,
|
||||
project: `/api/projects/${projectId.value}`,
|
||||
})
|
||||
await navigateTo(`/portal/projects/${projectId.value}`)
|
||||
} catch {
|
||||
// Toast already shown by useApi
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,40 +0,0 @@
|
||||
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<ClientTicket[]> {
|
||||
const query: Record<string, unknown> = {}
|
||||
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<HydraCollection<ClientTicket>>('/client_tickets', query)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getById(id: number): Promise<ClientTicket> {
|
||||
return api.get<ClientTicket>(`/client_tickets/${id}`)
|
||||
}
|
||||
|
||||
async function create(payload: ClientTicketWrite): Promise<ClientTicket> {
|
||||
return api.post<ClientTicket>('/client_tickets', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'portal.ticketCreated',
|
||||
})
|
||||
}
|
||||
|
||||
async function updateStatus(id: number, payload: ClientTicketStatusUpdate): Promise<ClientTicket> {
|
||||
return api.patch<ClientTicket>(`/client_tickets/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'clientTicket.statusUpdated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/client_tickets/${id}`, {}, {
|
||||
toastSuccessKey: 'clientTicket.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, getById, create, updateStatus, remove }
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -22,13 +22,6 @@ export type Task = {
|
||||
tags: TaskTag[]
|
||||
documents: TaskDocument[]
|
||||
archived: boolean
|
||||
clientTicket: {
|
||||
id: number
|
||||
number: number
|
||||
type: string
|
||||
status: string
|
||||
title: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export type TaskWrite = {
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
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[]
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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<Notification[]> {
|
||||
const data = await api.get<HydraCollection<Notification>>('/notifications')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function markAsRead(id: number): Promise<void> {
|
||||
await api.patch(`/notifications/${id}`, { isRead: true }, {
|
||||
toast: false,
|
||||
})
|
||||
}
|
||||
|
||||
async function markAllAsRead(): Promise<void> {
|
||||
await api.post('/notifications/mark-all-read', {}, {
|
||||
toast: false,
|
||||
})
|
||||
}
|
||||
|
||||
async function getUnreadCount(): Promise<number> {
|
||||
const data = await api.get<{ count: number }>('/notifications/unread-count', {}, {
|
||||
toast: false,
|
||||
})
|
||||
return data.count
|
||||
}
|
||||
|
||||
return { getAll, markAsRead, markAllAsRead, getUnreadCount }
|
||||
}
|
||||
@@ -28,26 +28,6 @@ export function useTaskDocumentService() {
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadForTicket(clientTicketId: number, file: File): Promise<TaskDocument> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('clientTicket', `/api/client_tickets/${clientTicketId}`)
|
||||
|
||||
return await $fetch<TaskDocument>(`${baseURL}/task_documents`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
// Do NOT set Content-Type — browser sets multipart boundary automatically
|
||||
})
|
||||
}
|
||||
|
||||
async function getByTicket(clientTicketId: number): Promise<TaskDocument[]> {
|
||||
const data = await api.get<HydraCollection<TaskDocument>>('/task_documents', {
|
||||
clientTicket: `/api/client_tickets/${clientTicketId}`,
|
||||
})
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/task_documents/${id}`, {}, {
|
||||
toastSuccessKey: 'taskDocuments.deleted',
|
||||
@@ -58,5 +38,5 @@ export function useTaskDocumentService() {
|
||||
return `${baseURL}/task_documents/${id}/download`
|
||||
}
|
||||
|
||||
return { getByTask, upload, uploadForTicket, getByTicket, remove, getDownloadUrl }
|
||||
return { getByTask, upload, remove, getDownloadUrl }
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260315182512 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260315183313 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE "user" ADD api_token VARCHAR(64) DEFAULT NULL');
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_8D93D6497BA2F5EB ON "user" (api_token)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('DROP INDEX UNIQ_8D93D6497BA2F5EB');
|
||||
$this->addSql('ALTER TABLE "user" DROP api_token');
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260315184538 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:generate-api-token',
|
||||
description: 'Generate or regenerate an API token for a user (used for MCP HTTP authentication)',
|
||||
)]
|
||||
class GenerateApiTokenCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\NotificationRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class MarkAllReadController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationRepository $notificationRepository,
|
||||
) {}
|
||||
|
||||
#[Route('/api/notifications/mark-all-read', name: 'notification_mark_all_read', methods: ['POST'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function __invoke(): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$this->notificationRepository->markAllReadByUser($user);
|
||||
|
||||
return new Response(null, Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\NotificationRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class NotificationUnreadCountController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationRepository $notificationRepository,
|
||||
) {}
|
||||
|
||||
#[Route('/api/notifications/unread-count', name: 'notification_unread_count', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$count = $this->notificationRepository->countUnreadByUser($user);
|
||||
|
||||
return new JsonResponse(['count' => $count]);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,6 @@ class AppFixtures extends Fixture
|
||||
$admin->setUsername('admin');
|
||||
$admin->setRoles(['ROLE_ADMIN']);
|
||||
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
|
||||
$admin->setApiToken('dev-mcp-token-for-testing-only-do-not-use-in-production');
|
||||
$manager->persist($admin);
|
||||
|
||||
// Clients
|
||||
|
||||
@@ -18,8 +18,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(),
|
||||
new Get(),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Repository\ClientTicketRepository;
|
||||
use App\State\ClientTicketNumberProcessor;
|
||||
use App\State\ClientTicketProvider;
|
||||
use App\State\ClientTicketStatusProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('ROLE_CLIENT') or is_granted('ROLE_ADMIN')",
|
||||
provider: ClientTicketProvider::class,
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('ROLE_CLIENT') or is_granted('ROLE_ADMIN')",
|
||||
provider: ClientTicketProvider::class,
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_CLIENT')",
|
||||
processor: ClientTicketNumberProcessor::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: ClientTicketStatusProcessor::class,
|
||||
),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['client_ticket:read']],
|
||||
denormalizationContext: ['groups' => ['client_ticket:write']],
|
||||
order: ['createdAt' => 'DESC'],
|
||||
)]
|
||||
#[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<int, TaskDocument> */
|
||||
#[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<int, TaskDocument> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use App\Repository\NotificationRepository;
|
||||
use App\State\NotificationProvider;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
provider: NotificationProvider::class,
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY')",
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('IS_AUTHENTICATED_FULLY') and object.getUser() == user",
|
||||
),
|
||||
],
|
||||
normalizationContext: ['groups' => ['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;
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,8 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(),
|
||||
new Get(),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
denormalizationContext: ['groups' => ['project:write', 'project:create']],
|
||||
|
||||
@@ -22,8 +22,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(paginationEnabled: false),
|
||||
new Get(),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')", processor: TaskNumberProcessor::class),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
@@ -104,11 +104,6 @@ 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();
|
||||
@@ -267,16 +262,4 @@ class Task
|
||||
{
|
||||
return $this->documents;
|
||||
}
|
||||
|
||||
public function getClientTicket(): ?ClientTicket
|
||||
{
|
||||
return $this->clientTicket;
|
||||
}
|
||||
|
||||
public function setClientTicket(?ClientTicket $clientTicket): static
|
||||
{
|
||||
$this->clientTicket = $clientTicket;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(paginationEnabled: false),
|
||||
new Get(),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')",
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
processor: TaskDocumentProcessor::class,
|
||||
deserialize: false,
|
||||
),
|
||||
@@ -40,42 +40,37 @@ class TaskDocument
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
|
||||
#[ORM\JoinColumn(nullable: false, 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', 'client_ticket:read'])]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?string $originalName = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?string $fileName = null;
|
||||
|
||||
#[ORM\Column(length: 100)]
|
||||
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?string $mimeType = null;
|
||||
|
||||
#[ORM\Column]
|
||||
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?int $size = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['task_document:read', 'task:read', 'client_ticket:read'])]
|
||||
#[Groups(['task_document:read', 'task:read'])]
|
||||
private ?User $uploadedBy = null;
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -166,16 +161,4 @@ class TaskDocument
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getClientTicket(): ?ClientTicket
|
||||
{
|
||||
return $this->clientTicket;
|
||||
}
|
||||
|
||||
public function setClientTicket(?ClientTicket $clientTicket): static
|
||||
{
|
||||
$this->clientTicket = $clientTicket;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(),
|
||||
new Get(),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
|
||||
@@ -19,8 +19,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(),
|
||||
new Get(),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
|
||||
@@ -16,8 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(),
|
||||
new Get(),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
|
||||
@@ -16,8 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(),
|
||||
new Get(),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
|
||||
@@ -16,8 +16,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(),
|
||||
new Get(),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
|
||||
@@ -24,16 +24,15 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(),
|
||||
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(security: "is_granted('ROLE_USER')"),
|
||||
new Get(),
|
||||
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"),
|
||||
|
||||
@@ -14,8 +14,6 @@ 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;
|
||||
@@ -48,11 +46,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])]
|
||||
#[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 180, unique: true)]
|
||||
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'client_ticket:read'])]
|
||||
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read'])]
|
||||
private ?string $username = null;
|
||||
|
||||
/** @var list<string> */
|
||||
@@ -67,24 +65,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private ?DateTimeImmutable $createdAt = null;
|
||||
|
||||
#[ORM\Column(length: 64, unique: true, nullable: true)]
|
||||
private ?string $apiToken = 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<int, Project> */
|
||||
#[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->allowedProjects = new ArrayCollection();
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
@@ -112,11 +95,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
/** @return list<string> */
|
||||
public function getRoles(): array
|
||||
{
|
||||
$roles = $this->roles;
|
||||
|
||||
if (!in_array('ROLE_CLIENT', $roles, true)) {
|
||||
$roles[] = 'ROLE_USER';
|
||||
}
|
||||
$roles = $this->roles;
|
||||
$roles[] = 'ROLE_USER';
|
||||
|
||||
return array_values(array_unique($roles));
|
||||
}
|
||||
@@ -153,51 +133,5 @@ 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<int, Project> */
|
||||
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 getApiToken(): ?string
|
||||
{
|
||||
return $this->apiToken;
|
||||
}
|
||||
|
||||
public function setApiToken(?string $apiToken): static
|
||||
{
|
||||
$this->apiToken = $apiToken;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void {}
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Project;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Repository\ClientRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'create-project', description: 'Create a new project. Code must be 2-10 uppercase letters.')]
|
||||
class CreateProjectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ClientRepository $clientRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $name,
|
||||
string $code,
|
||||
?string $description = null,
|
||||
?string $color = null,
|
||||
?int $clientId = null,
|
||||
): string {
|
||||
$project = new Project();
|
||||
$project->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Project;
|
||||
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'get-project', description: 'Get project details with task count summary per status')]
|
||||
class GetProjectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly TaskRepository $taskRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
$project = $this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Project;
|
||||
|
||||
use App\Repository\ProjectRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-projects', description: 'List all projects with optional archive filter')]
|
||||
class ListProjectsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(bool $archived = false): string
|
||||
{
|
||||
$projects = $this->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));
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Project;
|
||||
|
||||
use App\Repository\ClientRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-project', description: 'Update an existing project. Only provided fields are changed.')]
|
||||
class UpdateProjectTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly ClientRepository $clientRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $id,
|
||||
?string $name = null,
|
||||
?string $code = null,
|
||||
?string $description = null,
|
||||
?string $color = null,
|
||||
?int $clientId = null,
|
||||
?bool $archived = null,
|
||||
): string {
|
||||
$project = $this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Reference;
|
||||
|
||||
use App\Repository\ClientRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-clients', description: 'List all clients with their IDs, names, and emails. Use this to discover valid client IDs for project parameters.')]
|
||||
class ListClientsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClientRepository $clientRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
$clients = $this->clientRepository->findBy([], ['name' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($client) => [
|
||||
'id' => $client->getId(),
|
||||
'name' => $client->getName(),
|
||||
'email' => $client->getEmail(),
|
||||
], $clients));
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Reference;
|
||||
|
||||
use App\Repository\UserRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-users', description: 'List all users with their IDs and usernames. Use this to discover valid user IDs for assignee or time entry parameters.')]
|
||||
class ListUsersTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
$users = $this->userRepository->findBy([], ['username' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($user) => [
|
||||
'id' => $user->getId(),
|
||||
'username' => $user->getUsername(),
|
||||
], $users));
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Task;
|
||||
|
||||
use App\Entity\Task;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TaskEffortRepository;
|
||||
use App\Repository\TaskGroupRepository;
|
||||
use App\Repository\TaskPriorityRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Repository\TaskStatusRepository;
|
||||
use App\Repository\TaskTagRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'create-task', description: 'Create a new task in a project. The task number is auto-generated. Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover valid IDs.')]
|
||||
class CreateTaskTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly TaskStatusRepository $taskStatusRepository,
|
||||
private readonly TaskPriorityRepository $taskPriorityRepository,
|
||||
private readonly TaskEffortRepository $taskEffortRepository,
|
||||
private readonly TaskGroupRepository $taskGroupRepository,
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly UserRepository $userRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $projectId,
|
||||
string $title,
|
||||
?string $description = null,
|
||||
?int $statusId = null,
|
||||
?int $priorityId = null,
|
||||
?int $effortId = null,
|
||||
?int $assigneeId = null,
|
||||
?int $groupId = null,
|
||||
?array $tagIds = null,
|
||||
): string {
|
||||
$project = $this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Task;
|
||||
|
||||
use App\Repository\TaskRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'delete-task', description: 'Delete a task permanently. This also deletes all associated documents.')]
|
||||
class DeleteTaskTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
$task = $this->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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Task;
|
||||
|
||||
use App\Repository\TaskRepository;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'get-task', description: 'Get full task details including description, all relations, and documents')]
|
||||
class GetTaskTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskRepository $taskRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
$task = $this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Task;
|
||||
|
||||
use App\Repository\TaskRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')]
|
||||
class ListTasksTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskRepository $taskRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
?int $projectId = null,
|
||||
?int $statusId = null,
|
||||
?int $assigneeId = null,
|
||||
?int $priorityId = null,
|
||||
?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')
|
||||
->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)));
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Task;
|
||||
|
||||
use App\Repository\TaskEffortRepository;
|
||||
use App\Repository\TaskGroupRepository;
|
||||
use App\Repository\TaskPriorityRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Repository\TaskStatusRepository;
|
||||
use App\Repository\TaskTagRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-task', description: 'Update an existing task. Only provided fields are changed. Use list-statuses, list-priorities, etc. to discover valid IDs.')]
|
||||
class UpdateTaskTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly TaskStatusRepository $taskStatusRepository,
|
||||
private readonly TaskPriorityRepository $taskPriorityRepository,
|
||||
private readonly TaskEffortRepository $taskEffortRepository,
|
||||
private readonly TaskGroupRepository $taskGroupRepository,
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly UserRepository $userRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $id,
|
||||
?string $title = null,
|
||||
?string $description = null,
|
||||
?int $statusId = null,
|
||||
?int $priorityId = null,
|
||||
?int $effortId = null,
|
||||
?int $assigneeId = null,
|
||||
?int $groupId = null,
|
||||
?array $tagIds = null,
|
||||
?bool $archived = null,
|
||||
): string {
|
||||
$task = $this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Entity\TaskGroup;
|
||||
use App\Repository\ProjectRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'create-group', description: 'Create a new task group for a project')]
|
||||
class CreateGroupTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $projectId,
|
||||
string $title,
|
||||
?string $description = null,
|
||||
?string $color = null,
|
||||
): string {
|
||||
$project = $this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskEffortRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-efforts', description: 'List all task effort levels. Efforts are global (shared across all projects).')]
|
||||
class ListEffortsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskEffortRepository $taskEffortRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
$efforts = $this->taskEffortRepository->findBy([], ['label' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($e) => [
|
||||
'id' => $e->getId(),
|
||||
'label' => $e->getLabel(),
|
||||
], $efforts));
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskGroupRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-groups', description: 'List task groups, optionally filtered by project. Groups are per-project (each group belongs to one project).')]
|
||||
class ListGroupsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskGroupRepository $taskGroupRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(?int $projectId = null, bool $archived = false): string
|
||||
{
|
||||
$criteria = ['archived' => $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));
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskPriorityRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-priorities', description: 'List all task priorities. Priorities are global (shared across all projects).')]
|
||||
class ListPrioritiesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskPriorityRepository $taskPriorityRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
$priorities = $this->taskPriorityRepository->findBy([], ['label' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($p) => [
|
||||
'id' => $p->getId(),
|
||||
'label' => $p->getLabel(),
|
||||
'color' => $p->getColor(),
|
||||
], $priorities));
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskStatusRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-statuses', description: 'List all task statuses ordered by position. Statuses are global (shared across all projects). Use the returned IDs when creating or updating tasks.')]
|
||||
class ListStatusesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskStatusRepository $taskStatusRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
$statuses = $this->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));
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskTagRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-tags', description: 'List all task tags. Tags are global (shared across all projects).')]
|
||||
class ListTagsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(): string
|
||||
{
|
||||
$tags = $this->taskTagRepository->findBy([], ['label' => 'ASC']);
|
||||
|
||||
return json_encode(array_map(fn ($t) => [
|
||||
'id' => $t->getId(),
|
||||
'label' => $t->getLabel(),
|
||||
'color' => $t->getColor(),
|
||||
], $tags));
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TaskMeta;
|
||||
|
||||
use App\Repository\TaskGroupRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-group', description: 'Update an existing task group. Only provided fields are changed.')]
|
||||
class UpdateGroupTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TaskGroupRepository $taskGroupRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $id,
|
||||
?string $title = null,
|
||||
?string $description = null,
|
||||
?string $color = null,
|
||||
?bool $archived = null,
|
||||
): string {
|
||||
$group = $this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TimeEntry;
|
||||
|
||||
use App\Entity\TimeEntry;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Repository\TaskTagRepository;
|
||||
use App\Repository\TimeEntryRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'create-time-entry', description: 'Create a time entry. If stoppedAt is null, creates an active timer. Only one active timer per user is allowed.')]
|
||||
class CreateTimeEntryTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserRepository $userRepository,
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly TimeEntryRepository $timeEntryRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $userId,
|
||||
string $startedAt,
|
||||
?string $title = null,
|
||||
?string $stoppedAt = null,
|
||||
?int $projectId = null,
|
||||
?int $taskId = null,
|
||||
?array $tagIds = null,
|
||||
?string $description = null,
|
||||
): string {
|
||||
$user = $this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TimeEntry;
|
||||
|
||||
use App\Repository\TimeEntryRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'delete-time-entry', description: 'Delete a time entry permanently')]
|
||||
class DeleteTimeEntryTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimeEntryRepository $timeEntryRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
$entry = $this->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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TimeEntry;
|
||||
|
||||
use App\Repository\TimeEntryRepository;
|
||||
use DateTimeImmutable;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(name: 'list-time-entries', description: 'List time entries with optional filters. Duration is computed in minutes and null for active timers.')]
|
||||
class ListTimeEntriesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimeEntryRepository $timeEntryRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
?int $userId = null,
|
||||
?int $projectId = null,
|
||||
?int $taskId = null,
|
||||
?string $startDate = null,
|
||||
?string $endDate = null,
|
||||
int $limit = 100,
|
||||
): string {
|
||||
$limit = min($limit, 200);
|
||||
|
||||
$qb = $this->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));
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\TimeEntry;
|
||||
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Repository\TaskTagRepository;
|
||||
use App\Repository\TimeEntryRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-time-entry', description: 'Update a time entry. Use to stop an active timer by providing stoppedAt, or to correct start time. userId is not updatable.')]
|
||||
class UpdateTimeEntryTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimeEntryRepository $timeEntryRepository,
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
int $id,
|
||||
?string $title = null,
|
||||
?string $startedAt = null,
|
||||
?string $stoppedAt = null,
|
||||
?int $projectId = null,
|
||||
?int $taskId = null,
|
||||
?array $tagIds = null,
|
||||
?string $description = null,
|
||||
): string {
|
||||
$entry = $this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\ClientTicket;
|
||||
use App\Entity\Project;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ClientTicket>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Notification;
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Notification>
|
||||
*/
|
||||
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()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -17,25 +17,4 @@ class UserRepository extends ServiceEntityRepository
|
||||
{
|
||||
parent::__construct($registry, User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return User[]
|
||||
*/
|
||||
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.id IN (:ids)')
|
||||
->setParameter('ids', $ids)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||
|
||||
class ApiTokenAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserRepository $userRepository,
|
||||
) {}
|
||||
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
return $request->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);
|
||||
|
||||
if ('' === $token) {
|
||||
throw new CustomUserMessageAuthenticationException('API token missing.');
|
||||
}
|
||||
|
||||
return new SelfValidatingPassport(
|
||||
new UserBadge($token, function (string $token): ?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;
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||
{
|
||||
return new JsonResponse(
|
||||
['error' => $exception->getMessageKey()],
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\ClientTicket;
|
||||
use App\Entity\Notification;
|
||||
use App\Repository\UserRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final readonly class NotificationService
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private UserRepository $userRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Notify all ROLE_ADMIN users that a new ticket was created.
|
||||
*/
|
||||
public function createForTicketCreated(ClientTicket $ticket): void
|
||||
{
|
||||
$admins = $this->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();
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
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;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<ClientTicket, ClientTicket>
|
||||
*/
|
||||
final readonly class ClientTicketNumberProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
private ClientTicketRepository $clientTicketRepository,
|
||||
private NotificationService $notificationService,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
|
||||
{
|
||||
assert($data instanceof ClientTicket);
|
||||
|
||||
$user = $this->security->getUser();
|
||||
assert($user instanceof User);
|
||||
|
||||
$project = $data->getProject();
|
||||
if (null === $project) {
|
||||
throw new BadRequestHttpException('Project is required.');
|
||||
}
|
||||
|
||||
// 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);
|
||||
$data->setNumber($nextNumber);
|
||||
$data->setSubmittedBy($user);
|
||||
$data->setStatus('new');
|
||||
$data->setCreatedAt(new DateTimeImmutable());
|
||||
$data->setUpdatedAt(new DateTimeImmutable());
|
||||
|
||||
$this->entityManager->persist($data);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->notificationService->createForTicketCreated($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\ClientTicket;
|
||||
use App\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<ClientTicket>
|
||||
*/
|
||||
final readonly class ClientTicketProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|ClientTicket|null
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
assert($user instanceof 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();
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* @implements ProcessorInterface<ClientTicket, ClientTicket>
|
||||
*/
|
||||
final readonly class ClientTicketStatusProcessor implements ProcessorInterface
|
||||
{
|
||||
private const FORBIDDEN_TRANSITIONS = [
|
||||
'done' => ['new'],
|
||||
'rejected' => ['new'],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private NotificationService $notificationService,
|
||||
) {}
|
||||
|
||||
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();
|
||||
|
||||
$this->notificationService->createForStatusChange($data);
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\Notification;
|
||||
use App\Repository\NotificationRepository;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<Notification>
|
||||
*/
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,12 @@ 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;
|
||||
|
||||
@@ -52,41 +50,18 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
|
||||
throw new BadRequestHttpException('File size exceeds 50 MB limit.');
|
||||
}
|
||||
|
||||
$taskIri = $request->request->get('task');
|
||||
$clientTicketIri = $request->request->get('clientTicket');
|
||||
$taskIri = $request->request->get('task');
|
||||
|
||||
if ((null === $taskIri || '' === $taskIri) && (null === $clientTicketIri || '' === $clientTicketIri)) {
|
||||
throw new BadRequestHttpException('Either task or clientTicket IRI is required.');
|
||||
if (null === $taskIri || '' === $taskIri) {
|
||||
throw new BadRequestHttpException('Task IRI is required.');
|
||||
}
|
||||
|
||||
$task = null;
|
||||
$clientTicket = null;
|
||||
// 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 !== $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.');
|
||||
}
|
||||
}
|
||||
if (null === $task) {
|
||||
throw new BadRequestHttpException('Task not found.');
|
||||
}
|
||||
|
||||
// Capture file metadata BEFORE move() — move invalidates the temp file
|
||||
@@ -105,7 +80,6 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
|
||||
|
||||
$document = new TaskDocument();
|
||||
$document->setTask($task);
|
||||
$document->setClientTicket($clientTicket);
|
||||
$document->setOriginalName($originalName);
|
||||
$document->setFileName($fileName);
|
||||
$document->setMimeType($mimeType);
|
||||
|
||||
15
symfony.lock
15
symfony.lock
@@ -97,18 +97,6 @@
|
||||
"config/packages/nelmio_cors.yaml"
|
||||
]
|
||||
},
|
||||
"php-http/discovery": {
|
||||
"version": "1.20",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.18",
|
||||
"ref": "f45b5dd173a27873ab19f5e3180b2f661c21de02"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/http_discovery.yaml"
|
||||
]
|
||||
},
|
||||
"phpunit/phpunit": {
|
||||
"version": "13.0",
|
||||
"recipe": {
|
||||
@@ -169,9 +157,6 @@
|
||||
".editorconfig"
|
||||
]
|
||||
},
|
||||
"symfony/mcp-bundle": {
|
||||
"version": "v0.6.0"
|
||||
},
|
||||
"symfony/property-info": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
|
||||
Reference in New Issue
Block a user