diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6699076 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[{compose.yaml,compose.*.yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env b/.env new file mode 100644 index 0000000..0a412c9 --- /dev/null +++ b/.env @@ -0,0 +1,23 @@ +APP_ENV=dev +APP_SECRET="change_me_in_env_local" +APP_DEBUG=1 + +DEFAULT_URI=http://localhost/ + +###> nelmio/cors-bundle ### +CORS_ALLOW_ORIGIN='^https?://(localhost|127.0.0.1)(:[0-9]+)?$' +###< nelmio/cors-bundle ### + +###> lexik/jwt-authentication-bundle ### +JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem +JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem +JWT_PASSPHRASE=change_me_in_env_local +JWT_COOKIE_SECURE=0 +JWT_TOKEN_TTL=86400 +JWT_COOKIE_TTL=86400 +###< lexik/jwt-authentication-bundle ### + + +DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8" + +ENCRYPTION_KEY=change_me_in_env_local \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..55794b5 --- /dev/null +++ b/.env.example @@ -0,0 +1,99 @@ +############################################################################### +# Lesstime — Fichier d'environnement de reference +# +# Copiez ce fichier en .env.local et remplissez les valeurs sensibles. +# Les valeurs par defaut dans .env suffisent pour le developpement ; +# seuls les secrets (APP_SECRET, JWT_PASSPHRASE, ENCRYPTION_KEY) doivent +# etre definis dans .env.local. +# +# Ne commitez JAMAIS de vrais secrets dans .env ou .env.example. +############################################################################### + +# =========================================================================== +# App +# =========================================================================== + +# Environnement Symfony : dev, test, prod +APP_ENV=dev + +# Secret applicatif Symfony (32 chars hex) — a generer pour chaque installation +# Generer avec : php -r "echo bin2hex(random_bytes(16));" +APP_SECRET="change_me_in_env_local" + +# Active/desactive le mode debug (1 = oui, 0 = non) +APP_DEBUG=1 + +# URI par defaut de l'application (utilise pour les liens absolus) +DEFAULT_URI=http://localhost/ + +# =========================================================================== +# CORS (nelmio/cors-bundle) +# =========================================================================== + +# Origines autorisees pour les requetes cross-origin (regex) +CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' + +# =========================================================================== +# JWT (lexik/jwt-authentication-bundle) +# =========================================================================== + +# Chemin vers la cle privee RSA pour signer les tokens JWT +JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem + +# Chemin vers la cle publique RSA pour verifier les tokens JWT +JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem + +# Passphrase de la cle privee JWT — a generer pour chaque installation +# Generer avec : php -r "echo bin2hex(random_bytes(32));" +JWT_PASSPHRASE=change_me_in_env_local + +# Cookie securise (1 = HTTPS uniquement, 0 = HTTP autorise — dev seulement) +JWT_COOKIE_SECURE=0 + +# Duree de vie du token JWT en secondes (86400 = 24h) +JWT_TOKEN_TTL=86400 + +# Duree de vie du cookie JWT en secondes (86400 = 24h) +JWT_COOKIE_TTL=86400 + +# =========================================================================== +# Base de donnees (Doctrine / PostgreSQL) +# =========================================================================== + +# Les variables POSTGRES_* sont definies dans docker/.env.docker +# et injectees automatiquement par Docker Compose. +# DATABASE_URL est construite a partir de ces variables. +DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8" + +# =========================================================================== +# Chiffrement +# =========================================================================== + +# Cle de chiffrement pour les donnees sensibles (64 chars hex = 256 bits) +# Generer avec : php -r "echo bin2hex(random_bytes(32));" +ENCRYPTION_KEY=change_me_in_env_local + +# =========================================================================== +# Docker (docker/.env.docker) +# +# Ces variables sont lues par Docker Compose. Voir docker/.env.docker +# pour les valeurs par defaut. Creez docker/.env.docker.local pour +# surcharger localement. +# =========================================================================== + +# DOCKER_APP_NAME=lesstime +# DOCKER_PHP_VERSION=8.4.6 +# DOCKER_NODE_VERSION=24.12.0 +# APP_USER=www-data +# POSTGRES_DB=lesstime +# POSTGRES_USER=root +# POSTGRES_PASSWORD=root +# POSTGRES_PORT=5435 +# XDEBUG_CLIENT_HOST=host.docker.internal + +# =========================================================================== +# Frontend (frontend/.env) +# =========================================================================== + +# Base URL de l'API pour le client Nuxt (relative, proxifiee par Nginx) +# NUXT_PUBLIC_API_BASE=/api diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..aed2bc5 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ +--- + +name: "Merge Request" +about: "Template de MR" +title: "[#NUMERO_TICKET] TITRE TICKET" +ref: "main" + +--- + +| Numéro du ticket | Titre du ticket | +|------------------|-----------------| +| | | + +## Description de la PR + +## Modification du .env + +## Check list + +- [ ] Pas de régression +- [ ] TU/TI/TF rédigée +- [ ] TU/TI/TF OK +- [ ] CHANGELOG modifié diff --git a/.gitea/workflows/auto-tag-develop.yml b/.gitea/workflows/auto-tag-develop.yml new file mode 100644 index 0000000..48f28d5 --- /dev/null +++ b/.gitea/workflows/auto-tag-develop.yml @@ -0,0 +1,65 @@ +name: Auto Tag Develop + +on: + push: + branches: + - develop + +jobs: + tag: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN }} + persist-credentials: true + + - name: Create next tag from config/version.yaml + shell: bash + run: | + set -euo pipefail + + # Skip if current commit already has a vX.Y.Z tag + if git tag --points-at HEAD | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Tag already exists on this commit. Skipping." + exit 0 + fi + + changed_version=false + if git diff --name-only "${{ gitea.event.before }}" "${{ gitea.event.after }}" | grep -q '^config/version\.yaml$'; then + changed_version=true + fi + + read_version() { + awk -F': *' '/app\.version:/{print $2}' config/version.yaml | tr -d '[:space:]' | tr -d "'\"" + } + + if $changed_version; then + version="$(read_version)" + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid version in version.yaml: $version" >&2 + exit 1 + fi + else + last_tag="$(git tag -l 'v*' --sort=-v:refname | head -n1 || true)" + if [ -z "$last_tag" ]; then + version="0.1.0" + else + base="${last_tag#v}" + IFS='.' read -r major minor patch <<< "$base" + version="${major}.${minor}.$((patch + 1))" + fi + + printf "parameters:\\n app.version: '%s'\\n" "$version" > config/version.yaml + git config user.name "gitea-actions" + git config user.email "gitea-actions@local" + git add config/version.yaml + git commit -m "chore: bump version to v$version" || true + git push origin develop || true + fi + + tag="v$version" + git tag "$tag" + git push origin "$tag" diff --git a/.gitea/workflows/release-artefact.yml b/.gitea/workflows/release-artefact.yml new file mode 100644 index 0000000..9c0f36a --- /dev/null +++ b/.gitea/workflows/release-artefact.yml @@ -0,0 +1,65 @@ +name: Build Release Artefact + +on: + push: + tags: + - "v*" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "lts/*" + + - name: Install backend deps (prod) + env: + APP_ENV: prod + APP_DEBUG: "0" + run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts + + - name: Build frontend (static) + run: | + cd frontend + npm ci + CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate + test -f .output/public/index.html + + - name: Build artefact + shell: bash + run: | + set -euo pipefail + mkdir -p release + tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \ + .env \ + bin \ + config \ + migrations \ + public \ + src \ + vendor \ + composer.json \ + composer.lock \ + symfony.lock \ + frontend/.output + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: release/lesstime-${{ github.ref_name }}.tar.gz + env: + GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e60d19 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ + +###> symfony/framework-bundle ### +/.env.local +/.env.local.php +/.env.*.local +/config/secrets/prod/prod.decrypt.private.php +/public/bundles/ +/var/ +/vendor/ +###< symfony/framework-bundle ### + +###> friendsofphp/php-cs-fixer ### +/.php-cs-fixer.php +/.php-cs-fixer.cache +###< friendsofphp/php-cs-fixer ### + +###> phpunit/phpunit ### +/phpunit.xml +/.phpunit.cache/ +###< phpunit/phpunit ### + +###> lexik/jwt-authentication-bundle ### +/config/jwt/*.pem +###< lexik/jwt-authentication-bundle ### + +###> ide ### +.idea/ +###< ide ### + +###> docker local ### +docker/.env.docker.local +###< docker local ### diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..1c7cc70 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "lesstime": { + "command": "docker", + "args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"] + } + } +} diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..248216a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.12.0 diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..04b76c2 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,56 @@ +in('src') + ->notName('Kernel.php') +; + +$rules = [ + '@Symfony' => true, + '@PSR12' => true, + '@PHP84Migration' => true, + '@PER-CS' => true, + '@PhpCsFixer' => true, + 'strict_param' => true, + 'strict_comparison' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'binary_operator_spaces' => [ + 'operators' => [ + '=' => 'align_single_space_minimal', + '||' => 'align_single_space_minimal', + '=>' => 'align_single_space_minimal', + ], + ], + 'global_namespace_import' => [ + 'import_classes' => true, + 'import_constants' => true, + 'import_functions' => true, + ], + 'modernize_strpos' => true, // needs PHP 8+ or polyfill + 'no_superfluous_phpdoc_tags' => true, + 'echo_tag_syntax' => true, + 'semicolon_after_instruction' => true, + 'combine_consecutive_unsets' => true, + 'ternary_to_null_coalescing' => true, + 'declare_strict_types' => true, + 'operator_linebreak' => [ + 'position' => 'beginning', + ], + 'no_unused_imports' => true, + 'single_line_throw' => false, + 'php_unit_test_class_requires_covers' => false, +]; + +$config = new Config(); + +return $config + ->setRiskyAllowed(true) + ->setRules($rules) + ->setFinder($finder) +; diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..654b363 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,128 @@ +# Lesstime + +Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4. + +## Stack + +- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16 +- **Frontend** : Nuxt 4 (SSR off / SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, nuxt-toast, @nuxtjs/i18n, @nuxt/icon +- **Auth** : JWT HTTP-only cookie (lexik/jwt-authentication-bundle), login à `/login_check`, cookie `BEARER` +- **Docker** : PHP-FPM + Node 24, Nginx (port 8082), PostgreSQL (port 5435) + +## Structure + +``` +src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink) +src/ApiResource/ # Ressources API Platform (si découplées des entités) +src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor) +src/Service/ # Services métier (NotificationService) +src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController) +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) +config/jwt/ # Clés JWT (private.pem, public.pem) +migrations/ # Migrations Doctrine +docs/plans/ # Plans d'implémentation +docs/superpowers/ # Plans et specs superpowers +frontend/ # App Nuxt 4 +frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket) +frontend/layouts/ # Layouts (default, portal) +frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/) +frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService) +frontend/stores/ # Stores Pinia (auth, ui, timer) +frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents) +frontend/services/dto/ # Types TypeScript +frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/) +``` + +## Commandes + +```bash +make start # Démarrer les containers +make stop # Arrêter les containers +make restart # Redémarrer les containers +make install # Install complet (composer, migrations, fixtures, build Nuxt) +make reset # Tout supprimer et réinstaller (supprime la BDD) +make dev-nuxt # Dev server Nuxt (hot reload, port 3002) +make shell # Shell dans le container PHP +make shell-root # Shell root dans le container PHP +make cache-clear # Vider le cache Symfony +make migration-migrate # Lancer les migrations +make fixtures # Charger les fixtures +make db-reset # Reset BDD + migrations + fixtures +make test # PHPUnit +make php-cs-fixer-allow-risky # Fix code style PHP +make logs-dev # Tail logs Symfony +``` + +## Conventions + +### Commits + +Format : `() : ` (espace avant et après `:`) + +Types autorisés (minuscules) : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test` + +Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` + +### Backend + +- Toujours `declare(strict_types=1)` en haut des fichiers PHP +- API Platform : utiliser ApiResource, Providers (`src/State/`), Processors — pas de controllers +- Routes API préfixées `/api` (via `config/routes/api_platform.yaml`) +- Le login (`/login_check`) est hors prefix `/api`, nginx réécrit `REQUEST_URI` vers `/login_check` +- PHP CS Fixer : règles Symfony + PSR-12 + strict types +- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT` — hiérarchie dans `security.yaml` +- `User::getRoles()` n'ajoute PAS `ROLE_USER` si l'user a `ROLE_CLIENT` (isolation) +- PostgreSQL : `LIKE` sur colonne JSON ne marche pas → utiliser `roles::text LIKE` via native SQL +- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}` +- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux propriétés de l'entité cible +- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider côté serveur — nécessite `symfony/mime` +- Auth endpoints mixtes (ROLE_USER + ROLE_CLIENT) : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique + +### Frontend + +- TypeScript strict +- Composable `useApi()` pour tous les appels API (gère cookies, erreurs, toasts, i18n) +- Stores Pinia : `useAuthStore` (auth), `useUiStore` (ui), `useTimerStore` (timer) +- Middleware global `auth.global.ts` protège les routes +- Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`) +- 4 espaces d'indentation +- MalioSelect : options `{ label: string, value: number | null }` uniquement — pas de string values, utiliser ` + + +``` + +- [ ] **Step 2: Update form reactive and populate logic** + +Add `isFinal` to the form reactive (line 56-60): + +```typescript +const form = reactive({ + label: '', + position: '0', + color: '#222783', + isFinal: false, +}) +``` + +Update the watcher populate (line 66-79) to include `isFinal`: + +```typescript +watch(() => props.modelValue, (open) => { + if (open) { + if (props.item) { + form.label = props.item.label ?? '' + form.position = String(props.item.position ?? 0) + form.color = props.item.color ?? '#222783' + form.isFinal = props.item.isFinal ?? false + } else { + form.label = '' + form.position = '0' + form.color = '#222783' + form.isFinal = false + } + touched.label = false + } +}) +``` + +Update the payload (line 89-93): + +```typescript +const payload: TaskStatusWrite = { + label: form.label.trim(), + position: Number(form.position), + color: form.color, + isFinal: form.isFinal, +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add frontend/components/task/TaskStatusDrawer.vue +git commit -m "feat(frontend) : add isFinal toggle to TaskStatusDrawer" +``` + +### Task 17: Verify everything works end-to-end + +- [ ] **Step 1: Run the dev server** + +```bash +make dev-nuxt +``` + +- [ ] **Step 2: Manual verification checklist** + +1. Create/edit a status in admin → verify `isFinal` checkbox works +2. Set a task to "Terminé" status → verify "Archiver" button appears in TaskDrawer +3. Archive a task → verify it disappears from kanban +4. Go to Archives page → verify the archived task appears +5. Unarchive the task → verify it reappears in kanban +6. Delete button → verify confirmation modal appears +7. In Groups page → verify archive button shows when all group tasks are final +8. Archive a group → verify group and tasks disappear from kanban +9. Toggle "Voir les groupes archivés" → verify archived groups appear with unarchive button + +- [ ] **Step 3: Final commit if any fixes needed** diff --git a/docs/superpowers/plans/2026-03-12-time-entry-multi-type-select.md b/docs/superpowers/plans/2026-03-12-time-entry-multi-type-select.md new file mode 100644 index 0000000..f62d669 --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-time-entry-multi-type-select.md @@ -0,0 +1,158 @@ +# Time Entry Multi-Type Selection Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow selecting multiple task types in the TimeEntryDrawer, matching the existing multi-select pattern used in TaskDrawer. + +**Architecture:** Replace the single-select `MalioSelect` dropdown for types with the checkbox-based colored badge multi-select already used in TaskDrawer. The backend (ManyToMany relation) and DTO (`types: string[]`) already support multiple types — only the frontend form state and template need updating. + +**Tech Stack:** Vue 3, TypeScript + +--- + +## Chunk 1: Multi-Type Select in TimeEntryDrawer + +### Task 1: Update TimeEntryDrawer to support multiple type selection + +**Files:** +- Modify: `frontend/components/time-tracking/TimeEntryDrawer.vue` + +- [ ] **Step 1: Change form state from single typeId to typeIds array** + +In the `form` reactive object (line 133-142), replace: + +```typescript +typeId: null as number | null, +``` + +with: + +```typescript +typeIds: [] as number[], +``` + +- [ ] **Step 2: Add toggleType function** + +Add this function after the `durationLabel` computed (after line 165): + +```typescript +function toggleType(id: number) { + const idx = form.typeIds.indexOf(id) + if (idx >= 0) { + form.typeIds.splice(idx, 1) + } else { + form.typeIds.push(id) + } +} +``` + +- [ ] **Step 3: Remove the typeOptions computed** + +Delete the `typeOptions` computed (lines 152-154): + +```typescript +const typeOptions = computed(() => + props.types.map(t => ({ label: t.label, value: t.id })) +) +``` + +This is no longer needed since we won't use `MalioSelect`. + +- [ ] **Step 4: Replace MalioSelect template with multi-select badges** + +Replace the `MalioSelect` for type (lines 75-81): + +```vue + +``` + +with: + +```vue +
+

Types

+
+ +
+
+``` + +- [ ] **Step 5: Update populateForm to use typeIds** + +In the `populateForm` function, replace (line 194): + +```typescript +form.typeId = entry.types?.[0]?.id ?? null +``` + +with: + +```typescript +form.typeIds = entry.types?.map(t => t.id) ?? [] +``` + +And in the else branch (line 203), replace: + +```typescript +form.typeId = null +``` + +with: + +```typescript +form.typeIds = [] +``` + +- [ ] **Step 6: Update onSubmit payload to use typeIds** + +In the `onSubmit` function, replace (line 233): + +```typescript +types: form.typeId ? [`/api/task_types/${form.typeId}`] : [], +``` + +with: + +```typescript +types: form.typeIds.map(id => `/api/task_types/${id}`), +``` + +- [ ] **Step 7: Verify in browser** + +Run: `make dev-nuxt` + +1. Open time tracking page +2. Open an existing time entry → verify existing types are pre-selected as colored badges +3. Toggle types on/off → verify visual feedback (colored background when selected) +4. Save → verify types are persisted correctly +5. Create a new time entry with multiple types → verify they save correctly + +- [ ] **Step 8: Commit** + +```bash +git add frontend/components/time-tracking/TimeEntryDrawer.vue +git commit -m "feat(frontend) : allow multiple type selection in time entry drawer" +``` diff --git a/docs/superpowers/plans/2026-03-13-gitea-integration.md b/docs/superpowers/plans/2026-03-13-gitea-integration.md new file mode 100644 index 0000000..19a9e76 --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-gitea-integration.md @@ -0,0 +1,2248 @@ +# Gitea Integration Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow users to create Gitea branches from tickets, and view branches/commits/PRs/CI status linked to tickets — all fetched on-demand from the Gitea API. + +**Architecture:** Backend adds a `GiteaConfiguration` singleton entity (encrypted token), extends `Project` with `gitea_owner`/`gitea_repo` fields, and provides a `GiteaApiService` that wraps Gitea REST API calls. Custom API Platform endpoints expose config management (admin) and task-level git info. Frontend adds an admin tab for Gitea config, repo selection in ProjectDrawer, and a `TaskGitSection` component in TaskModal. + +**Tech Stack:** PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, Nuxt 4, Vue 3, TypeScript, Tailwind CSS + +**Spec:** `docs/superpowers/specs/2026-03-13-gitea-integration-design.md` + +--- + +## Chunk 1: Backend — Entity & Migration + +### Task 1: GiteaConfiguration Entity + +**Files:** +- Create: `src/Entity/GiteaConfiguration.php` +- Create: `src/Repository/GiteaConfigurationRepository.php` + +- [ ] **Step 1: Create GiteaConfigurationRepository** + +```php +findOneBy([]); + } +} +``` + +- [ ] **Step 2: Create GiteaConfiguration entity** + +```php +id; + } + + public function getUrl(): ?string + { + return $this->url; + } + + public function setUrl(?string $url): static + { + $this->url = $url; + + return $this; + } + + public function getEncryptedToken(): ?string + { + return $this->encryptedToken; + } + + public function setEncryptedToken(?string $encryptedToken): static + { + $this->encryptedToken = $encryptedToken; + + return $this; + } + + public function hasToken(): bool + { + return null !== $this->encryptedToken; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/GiteaConfiguration.php src/Repository/GiteaConfigurationRepository.php +git commit -m "feat : add GiteaConfiguration entity with repository" +``` + +### Task 2: Add gitea fields to Project entity + +**Files:** +- Modify: `src/Entity/Project.php` + +- [ ] **Step 1: Add giteaOwner and giteaRepo fields to Project** + +Add after the `$client` property (around line 65): + +```php +#[ORM\Column(length: 255, nullable: true)] +#[Groups(['project:read', 'project:write'])] +private ?string $giteaOwner = null; + +#[ORM\Column(length: 255, nullable: true)] +#[Groups(['project:read', 'project:write'])] +private ?string $giteaRepo = null; +``` + +Add getters/setters at the end of the class: + +```php +public function getGiteaOwner(): ?string +{ + return $this->giteaOwner; +} + +public function setGiteaOwner(?string $giteaOwner): static +{ + $this->giteaOwner = $giteaOwner; + + return $this; +} + +public function getGiteaRepo(): ?string +{ + return $this->giteaRepo; +} + +public function setGiteaRepo(?string $giteaRepo): static +{ + $this->giteaRepo = $giteaRepo; + + return $this; +} + +public function hasGiteaRepo(): bool +{ + return null !== $this->giteaOwner && null !== $this->giteaRepo; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Entity/Project.php +git commit -m "feat : add gitea owner/repo fields to Project entity" +``` + +### Task 3: Generate and run migration + +**Files:** +- Create: `migrations/VersionYYYYMMDDHHMMSS.php` (auto-generated) + +- [ ] **Step 1: Generate migration** + +```bash +make shell +# Inside container: +php bin/console doctrine:migrations:diff +exit +``` + +- [ ] **Step 2: Run migration** + +```bash +make migration-migrate +``` + +- [ ] **Step 3: Commit** + +```bash +git add migrations/ +git commit -m "feat : add migration for GiteaConfiguration and Project gitea fields" +``` + +--- + +## Chunk 2: Backend — Token Encryption & GiteaApiService + +### Task 4: Token encryption service + +**Files:** +- Create: `src/Service/TokenEncryptor.php` + +- [ ] **Step 1: Add GITEA_ENCRYPTION_KEY to .env** + +Add to `.env`: + +``` +GITEA_ENCRYPTION_KEY= +``` + +- [ ] **Step 2: Create TokenEncryptor service** + +```php +key = sodium_hex2bin($encryptionKey); + + if (mb_strlen($this->key, '8bit') !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { + throw new \InvalidArgumentException('GITEA_ENCRYPTION_KEY must be a valid sodium secret box key.'); + } + } + + public function encrypt(string $plaintext): string + { + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $this->key); + + return sodium_bin2hex($nonce . $ciphertext); + } + + public function decrypt(string $encrypted): string + { + $decoded = sodium_hex2bin($encrypted); + $nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit'); + $ciphertext = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit'); + + $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key); + + if (false === $plaintext) { + throw new \RuntimeException('Failed to decrypt token.'); + } + + return $plaintext; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Service/TokenEncryptor.php .env +git commit -m "feat : add TokenEncryptor service with sodium encryption" +``` + +### Task 5: GiteaApiException + +**Files:** +- Create: `src/Exception/GiteaApiException.php` + +- [ ] **Step 1: Create exception class** + +```php +slugger = new AsciiSlugger('fr'); + } + + public function testConnection(): bool + { + try { + $this->request('GET', '/api/v1/version'); + + return true; + } catch (GiteaApiException) { + return false; + } + } + + /** + * @return array + */ + public function listRepositories(): array + { + $result = []; + $page = 1; + + do { + $data = $this->request('GET', '/api/v1/repos/search', [ + 'query' => ['page' => $page, 'limit' => 50], + ]); + $result = array_merge($result, $data['data'] ?? []); + ++$page; + } while (!empty($data['data']) && count($data['data']) === 50); + + return $result; + } + + public function getDefaultBranch(Project $project): string + { + $this->assertProjectHasRepo($project); + $data = $this->request('GET', sprintf( + '/api/v1/repos/%s/%s', + $project->getGiteaOwner(), + $project->getGiteaRepo(), + )); + + return $data['default_branch'] ?? 'main'; + } + + public function createBranch(Project $project, Task $task, string $type, string $baseBranch): string + { + $this->assertProjectHasRepo($project); + $branchName = $this->generateBranchName($task, $type); + + $this->request('POST', sprintf( + '/api/v1/repos/%s/%s/branches', + $project->getGiteaOwner(), + $project->getGiteaRepo(), + ), [ + 'json' => [ + 'new_branch_name' => $branchName, + 'old_branch_name' => $baseBranch, + ], + ]); + + return $branchName; + } + + public function generateBranchName(Task $task, string $type): string + { + $project = $task->getProject(); + if (null === $project) { + throw new GiteaApiException('Task has no project.'); + } + + $slug = $this->slugger->slug($task->getTitle())->lower()->truncate(50)->toString(); + $slug = rtrim($slug, '-'); + + return sprintf('%s/%s-%d-%s', $type, $project->getCode(), $task->getNumber(), $slug); + } + + /** + * @return array + */ + public function listBranches(Project $project, string $taskCode): array + { + $this->assertProjectHasRepo($project); + + $allBranches = []; + $page = 1; + + do { + $pageBranches = $this->request('GET', sprintf( + '/api/v1/repos/%s/%s/branches', + $project->getGiteaOwner(), + $project->getGiteaRepo(), + ), [ + 'query' => ['page' => $page, 'limit' => 50], + ]); + $allBranches = array_merge($allBranches, $pageBranches); + ++$page; + } while (!empty($pageBranches) && count($pageBranches) === 50); + + $regex = sprintf('#^[^/]+/%s($|-.+)#', preg_quote($taskCode, '#')); + + return array_values(array_filter($allBranches, static function (array $branch) use ($regex): bool { + return 1 === preg_match($regex, $branch['name']); + })); + } + + /** + * @return array + */ + public function listCommits(Project $project, string $branch): array + { + $this->assertProjectHasRepo($project); + + return $this->request('GET', sprintf( + '/api/v1/repos/%s/%s/commits', + $project->getGiteaOwner(), + $project->getGiteaRepo(), + ), [ + 'query' => ['sha' => $branch, 'limit' => 30], + ]); + } + + /** + * @return array + */ + public function listPullRequests(Project $project, string $taskCode): array + { + $this->assertProjectHasRepo($project); + + $branches = $this->listBranches($project, $taskCode); + $prs = []; + + foreach ($branches as $branch) { + $branchPrs = $this->request('GET', sprintf( + '/api/v1/repos/%s/%s/pulls', + $project->getGiteaOwner(), + $project->getGiteaRepo(), + ), [ + 'query' => ['state' => 'all', 'head' => $branch['name']], + ]); + $prs = array_merge($prs, $branchPrs); + } + + // Fetch CI status for each PR + foreach ($prs as &$pr) { + $sha = $pr['head']['sha'] ?? null; + if (null !== $sha) { + try { + $pr['ci_statuses'] = $this->request('GET', sprintf( + '/api/v1/repos/%s/%s/commits/%s/statuses', + $project->getGiteaOwner(), + $project->getGiteaRepo(), + $sha, + )); + } catch (GiteaApiException) { + $pr['ci_statuses'] = []; + } + } + } + + return $prs; + } + + private function getConfiguration(): GiteaConfiguration + { + $config = $this->configRepository->findSingleton(); + if (null === $config) { + throw new GiteaApiException('Gitea is not configured.'); + } + + return $config; + } + + private function getDecryptedToken(GiteaConfiguration $config): string + { + $encrypted = $config->getEncryptedToken(); + if (null === $encrypted) { + throw new GiteaApiException('Gitea token is not set.'); + } + + return $this->tokenEncryptor->decrypt($encrypted); + } + + private function assertProjectHasRepo(Project $project): void + { + if (!$project->hasGiteaRepo()) { + throw new GiteaApiException('Project has no Gitea repository configured.'); + } + } + + /** + * @param array $options + */ + private function request(string $method, string $path, array $options = []): array + { + $config = $this->getConfiguration(); + $token = $this->getDecryptedToken($config); + + $options['headers'] = array_merge($options['headers'] ?? [], [ + 'Authorization' => 'token ' . $token, + 'Accept' => 'application/json', + ]); + $options['timeout'] = 10; + + try { + $response = $this->httpClient->request($method, rtrim($config->getUrl(), '/') . $path, $options); + + return $response->toArray(); + } catch (ExceptionInterface $e) { + throw new GiteaApiException('Gitea API error: ' . $e->getMessage(), 0, $e); + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Service/GiteaApiService.php +git commit -m "feat : add GiteaApiService with branch/commit/PR methods" +``` + +--- + +## Chunk 3: Backend — API Endpoints + +### Task 7: Gitea Settings API Resource & Providers/Processors + +**Files:** +- Create: `src/ApiResource/GiteaSettings.php` +- Create: `src/State/GiteaSettingsProvider.php` +- Create: `src/State/GiteaSettingsProcessor.php` + +- [ ] **Step 1: Create GiteaSettings API Resource** + +```php + ['gitea_settings:read']], + provider: GiteaSettingsProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + new Put( + uriTemplate: '/settings/gitea', + denormalizationContext: ['groups' => ['gitea_settings:write']], + normalizationContext: ['groups' => ['gitea_settings:read']], + provider: GiteaSettingsProvider::class, + processor: GiteaSettingsProcessor::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class GiteaSettings +{ + #[Groups(['gitea_settings:read', 'gitea_settings:write'])] + public ?string $url = null; + + #[Groups(['gitea_settings:write'])] + public ?string $token = null; + + #[Groups(['gitea_settings:read'])] + public bool $hasToken = false; +} +``` + +- [ ] **Step 2: Create GiteaSettingsProvider** + +```php +configRepository->findSingleton(); + $dto = new GiteaSettings(); + + if (null !== $config) { + $dto->url = $config->getUrl(); + $dto->hasToken = $config->hasToken(); + } + + return $dto; + } +} +``` + +- [ ] **Step 3: Create GiteaSettingsProcessor** + +```php +configRepository->findSingleton(); + if (null === $config) { + $config = new GiteaConfiguration(); + } + + $config->setUrl($data->url); + + if (null !== $data->token && '' !== $data->token) { + $config->setEncryptedToken($this->tokenEncryptor->encrypt($data->token)); + } + + $this->em->persist($config); + $this->em->flush(); + + $result = new GiteaSettings(); + $result->url = $config->getUrl(); + $result->hasToken = $config->hasToken(); + + return $result; + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/ApiResource/GiteaSettings.php src/State/GiteaSettingsProvider.php src/State/GiteaSettingsProcessor.php +git commit -m "feat : add Gitea settings API resource with provider/processor" +``` + +### Task 8: Gitea Test Connection Endpoint + +**Files:** +- Create: `src/ApiResource/GiteaTestConnection.php` +- Create: `src/State/GiteaTestConnectionProvider.php` + +- [ ] **Step 1: Create GiteaTestConnection API Resource** + +```php + ['gitea_test:read']], + provider: GiteaTestConnectionProvider::class, + processor: GiteaTestConnectionProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class GiteaTestConnection +{ + #[Groups(['gitea_test:read'])] + public bool $success = false; +} +``` + +- [ ] **Step 2: Create GiteaTestConnectionProvider** + +```php +success = $this->giteaApiService->testConnection(); + + return $result; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/ApiResource/GiteaTestConnection.php src/State/GiteaTestConnectionProvider.php +git commit -m "feat : add Gitea test connection endpoint" +``` + +### Task 9: Gitea Repositories List Endpoint + +**Files:** +- Create: `src/ApiResource/GiteaRepository.php` +- Create: `src/State/GiteaRepositoryProvider.php` + +- [ ] **Step 1: Create GiteaRepository API Resource** + +```php + ['gitea_repo:read']], + provider: GiteaRepositoryProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class GiteaRepository +{ + #[Groups(['gitea_repo:read'])] + public string $fullName = ''; + + #[Groups(['gitea_repo:read'])] + public string $name = ''; + + #[Groups(['gitea_repo:read'])] + public string $owner = ''; +} +``` + +- [ ] **Step 2: Create GiteaRepositoryProvider** + +```php +giteaApiService->listRepositories(); + } catch (GiteaApiException) { + return []; + } + + return array_map(static function (array $repo): GiteaRepository { + $dto = new GiteaRepository(); + $dto->fullName = $repo['full_name'] ?? ''; + $dto->name = $repo['name'] ?? ''; + $dto->owner = $repo['owner']['login'] ?? ''; + + return $dto; + }, $repos); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/ApiResource/GiteaRepository.php src/State/GiteaRepositoryProvider.php +git commit -m "feat : add Gitea repositories list endpoint" +``` + +### Task 10: Task Gitea Branches Endpoint + +**Files:** +- Create: `src/ApiResource/GiteaBranch.php` +- Create: `src/State/GiteaBranchProvider.php` +- Create: `src/State/GiteaBranchProcessor.php` + +- [ ] **Step 1: Create GiteaBranch API Resource** + +```php + ['gitea_branch:read']], + provider: GiteaBranchProvider::class, + ), + new Post( + uriTemplate: '/tasks/{taskId}/gitea/branches', + denormalizationContext: ['groups' => ['gitea_branch:write']], + normalizationContext: ['groups' => ['gitea_branch:read']], + provider: GiteaBranchProvider::class, + processor: GiteaBranchProcessor::class, + ), + ], +)] +final class GiteaBranch +{ + #[Groups(['gitea_branch:read'])] + public string $name = ''; + + #[Groups(['gitea_branch:write'])] + public string $type = 'feature'; + + #[Groups(['gitea_branch:write'])] + public string $baseBranch = 'main'; + + /** + * @var array + */ + #[Groups(['gitea_branch:read'])] + public array $commits = []; +} +``` + +- [ ] **Step 2: Create GiteaBranchProvider** + +```php +em->getRepository(Task::class)->find($uriVariables['taskId'] ?? 0); + if (null === $task || null === $task->getProject()) { + return []; + } + + $project = $task->getProject(); + if (!$project->hasGiteaRepo()) { + return []; + } + + $taskCode = $project->getCode() . '-' . $task->getNumber(); + + try { + $branches = $this->giteaApiService->listBranches($project, $taskCode); + } catch (GiteaApiException) { + return []; + } + + $result = []; + foreach ($branches as $branch) { + $dto = new GiteaBranch(); + $dto->name = $branch['name']; + + try { + $commits = $this->giteaApiService->listCommits($project, $branch['name']); + $dto->commits = array_map(static fn(array $c): array => [ + 'sha' => substr($c['sha'] ?? '', 0, 7), + 'message' => $c['commit']['message'] ?? '', + 'author' => $c['commit']['author']['name'] ?? '', + 'date' => $c['commit']['author']['date'] ?? $c['created'] ?? '', + ], $commits); + } catch (GiteaApiException) { + $dto->commits = []; + } + + $result[] = $dto; + } + + return $result; + } +} +``` + +- [ ] **Step 3: Create GiteaBranchProcessor** + +```php +em->getRepository(Task::class)->find($uriVariables['taskId'] ?? 0); + if (null === $task || null === $task->getProject()) { + throw new NotFoundHttpException('Task not found.'); + } + + $project = $task->getProject(); + if (!$project->hasGiteaRepo()) { + throw new BadRequestHttpException('Project has no Gitea repository.'); + } + + if (!in_array($data->type, self::ALLOWED_TYPES, true)) { + throw new BadRequestHttpException('Invalid branch type.'); + } + + try { + $branchName = $this->giteaApiService->createBranch($project, $task, $data->type, $data->baseBranch); + } catch (GiteaApiException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + + $result = new GiteaBranch(); + $result->name = $branchName; + + return $result; + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/ApiResource/GiteaBranch.php src/State/GiteaBranchProvider.php src/State/GiteaBranchProcessor.php +git commit -m "feat : add task Gitea branches endpoints (list + create)" +``` + +### Task 11: Task Gitea Pull Requests Endpoint + +**Files:** +- Create: `src/ApiResource/GiteaPullRequest.php` +- Create: `src/State/GiteaPullRequestProvider.php` + +- [ ] **Step 1: Create GiteaPullRequest API Resource** + +```php + ['gitea_pr:read']], + provider: GiteaPullRequestProvider::class, + ), + ], +)] +final class GiteaPullRequest +{ + #[Groups(['gitea_pr:read'])] + public int $number = 0; + + #[Groups(['gitea_pr:read'])] + public string $title = ''; + + #[Groups(['gitea_pr:read'])] + public string $state = ''; + + #[Groups(['gitea_pr:read'])] + public bool $merged = false; + + #[Groups(['gitea_pr:read'])] + public string $headBranch = ''; + + #[Groups(['gitea_pr:read'])] + public string $author = ''; + + #[Groups(['gitea_pr:read'])] + public string $url = ''; + + /** + * @var array + */ + #[Groups(['gitea_pr:read'])] + public array $ciStatuses = []; +} +``` + +- [ ] **Step 2: Create GiteaPullRequestProvider** + +```php +em->getRepository(Task::class)->find($uriVariables['taskId'] ?? 0); + if (null === $task || null === $task->getProject()) { + return []; + } + + $project = $task->getProject(); + if (!$project->hasGiteaRepo()) { + return []; + } + + $taskCode = $project->getCode() . '-' . $task->getNumber(); + + try { + $prs = $this->giteaApiService->listPullRequests($project, $taskCode); + } catch (GiteaApiException) { + return []; + } + + return array_map(static function (array $pr): GiteaPullRequest { + $dto = new GiteaPullRequest(); + $dto->number = $pr['number'] ?? 0; + $dto->title = $pr['title'] ?? ''; + $dto->state = $pr['state'] ?? ''; + $dto->merged = $pr['merged'] ?? false; + $dto->headBranch = $pr['head']['ref'] ?? ''; + $dto->author = $pr['user']['login'] ?? ''; + $dto->url = $pr['html_url'] ?? ''; + $dto->ciStatuses = array_map(static fn(array $s): array => [ + 'context' => $s['context'] ?? '', + 'status' => $s['status'] ?? '', + 'target_url' => $s['target_url'] ?? '', + ], $pr['ci_statuses'] ?? []); + + return $dto; + }, $prs); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/ApiResource/GiteaPullRequest.php src/State/GiteaPullRequestProvider.php +git commit -m "feat : add task Gitea pull requests endpoint" +``` + +### Task 12: Branch Name Generation Endpoint + +**Files:** +- Create: `src/ApiResource/GiteaBranchName.php` +- Create: `src/State/GiteaBranchNameProvider.php` + +- [ ] **Step 1: Create GiteaBranchName API Resource** + +This endpoint generates the branch name without creating it (for the "copy" button). + +```php + ['gitea_branch_name:read']], + provider: GiteaBranchNameProvider::class, + ), + ], +)] +final class GiteaBranchName +{ + #[Groups(['gitea_branch_name:read'])] + public string $name = ''; +} +``` + +- [ ] **Step 2: Create GiteaBranchNameProvider** + +```php +em->getRepository(Task::class)->find($uriVariables['taskId'] ?? 0); + if (null === $task) { + throw new NotFoundHttpException('Task not found.'); + } + + $type = $uriVariables['type'] ?? 'feature'; + if (!in_array($type, self::ALLOWED_TYPES, true)) { + throw new BadRequestHttpException('Invalid branch type.'); + } + + $dto = new GiteaBranchName(); + $dto->name = $this->giteaApiService->generateBranchName($task, $type); + + return $dto; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/ApiResource/GiteaBranchName.php src/State/GiteaBranchNameProvider.php +git commit -m "feat : add branch name generation endpoint" +``` + +--- + +## Chunk 4: Frontend — Service Layer & DTOs + +### Task 13: Frontend Gitea DTOs + +**Files:** +- Create: `frontend/services/dto/gitea.ts` + +- [ ] **Step 1: Create Gitea DTOs** + +```typescript +export type GiteaSettings = { + url: string | null + hasToken: boolean +} + +export type GiteaSettingsWrite = { + url: string | null + token: string | null +} + +export type GiteaRepository = { + fullName: string + name: string + owner: string +} + +export type GiteaBranch = { + name: string + commits: GiteaCommit[] +} + +export type GiteaCommit = { + sha: string + message: string + author: string + date: string +} + +export type GiteaBranchCreate = { + type: string + baseBranch: string +} + +export type GiteaPullRequest = { + number: number + title: string + state: string + merged: boolean + headBranch: string + author: string + url: string + ciStatuses: GiteaCiStatus[] +} + +export type GiteaCiStatus = { + context: string + status: string + target_url: string +} + +export type GiteaBranchName = { + name: string +} + +export type GiteaTestResult = { + success: boolean +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/services/dto/gitea.ts +git commit -m "feat : add Gitea TypeScript DTOs" +``` + +### Task 14: Frontend Gitea Service + +**Files:** +- Create: `frontend/services/gitea.ts` + +- [ ] **Step 1: Create Gitea service** + +```typescript +import type { + GiteaSettings, + GiteaSettingsWrite, + GiteaRepository, + GiteaBranch, + GiteaBranchCreate, + GiteaPullRequest, + GiteaBranchName, + GiteaTestResult, +} from './dto/gitea' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export function useGiteaService() { + const api = useApi() + + async function getSettings(): Promise { + return api.get('/settings/gitea') + } + + async function saveSettings(payload: GiteaSettingsWrite): Promise { + return api.put('/settings/gitea', payload as Record, { + toastSuccessKey: 'gitea.settings.saved', + }) + } + + async function testConnection(): Promise { + return api.post('/settings/gitea/test') + } + + async function listRepositories(): Promise { + const data = await api.get>('/gitea/repositories') + return extractHydraMembers(data) + } + + async function listBranches(taskId: number): Promise { + const data = await api.get>(`/tasks/${taskId}/gitea/branches`) + return extractHydraMembers(data) + } + + async function createBranch(taskId: number, payload: GiteaBranchCreate): Promise { + return api.post(`/tasks/${taskId}/gitea/branches`, payload as Record, { + toastSuccessKey: 'gitea.branch.created', + }) + } + + async function listPullRequests(taskId: number): Promise { + const data = await api.get>(`/tasks/${taskId}/gitea/pull-requests`) + return extractHydraMembers(data) + } + + async function getBranchName(taskId: number, type: string): Promise { + return api.get(`/tasks/${taskId}/gitea/branch-name/${type}`) + } + + return { + getSettings, + saveSettings, + testConnection, + listRepositories, + listBranches, + createBranch, + listPullRequests, + getBranchName, + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/services/gitea.ts +git commit -m "feat : add Gitea frontend service" +``` + +### Task 15: Add Project gitea fields to DTO + +**Files:** +- Modify: `frontend/services/dto/project.ts` + +- [ ] **Step 1: Add giteaOwner and giteaRepo to Project type** + +Add to the `Project` type after `client`: + +```typescript +giteaOwner: string | null +giteaRepo: string | null +``` + +Add to `ProjectWrite` type after `client`: + +```typescript +giteaOwner?: string | null +giteaRepo?: string | null +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/services/dto/project.ts +git commit -m "feat : add gitea fields to Project DTO" +``` + +--- + +## Chunk 5: Frontend — Admin Gitea Tab + +### Task 16: i18n keys + +**Files:** +- Modify: `frontend/i18n/locales/fr.json` + +- [ ] **Step 1: Add Gitea i18n keys** + +Add a `"gitea"` section to the JSON: + +```json +"common": { + "cancel": "Annuler", + "loading": "Chargement..." +}, +"gitea": { + "settings": { + "title": "Configuration Gitea", + "url": "URL du serveur", + "urlPlaceholder": "https://git.example.com", + "token": "Token API", + "tokenPlaceholder": "Entrez un nouveau token", + "tokenConfigured": "Token configuré", + "save": "Enregistrer", + "saved": "Configuration Gitea sauvegardée.", + "testConnection": "Tester la connexion", + "testSuccess": "Connexion réussie.", + "testFailed": "Connexion échouée." + }, + "branch": { + "title": "Git", + "create": "Créer une branche", + "created": "Branche créée avec succès.", + "copy": "Copier le nom", + "copied": "Nom de branche copié.", + "type": "Type", + "baseBranch": "Branche de base", + "preview": "Aperçu", + "types": { + "feature": "feature", + "fix": "fix", + "refactor": "refactor", + "hotfix": "hotfix", + "chore": "chore" + }, + "noBranches": "Aucune branche liée.", + "commits": "Commits", + "noCommits": "Aucun commit." + }, + "pr": { + "title": "Pull Requests", + "noPrs": "Aucune pull request.", + "open": "Ouverte", + "merged": "Mergée", + "closed": "Fermée", + "ci": "CI/CD" + }, + "error": "Erreur de connexion à Gitea.", + "notConfigured": "Gitea non configuré pour ce projet." +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat : add Gitea i18n keys" +``` + +### Task 17: AdminGiteaTab component + +**Files:** +- Create: `frontend/components/admin/AdminGiteaTab.vue` + +- [ ] **Step 1: Create AdminGiteaTab** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/admin/AdminGiteaTab.vue +git commit -m "feat : add AdminGiteaTab component" +``` + +### Task 18: Add Gitea tab to admin page + +**Files:** +- Modify: `frontend/pages/admin.vue` + +- [ ] **Step 1: Add Gitea tab entry** + +Add to the `tabs` array: + +```typescript +{ key: 'gitea', label: 'Gitea' }, +``` + +Add the type to `TabKey`: + +Update the type union to include `'gitea'`. + +Add the component in template: + +```vue + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/pages/admin.vue +git commit -m "feat : add Gitea tab to admin page" +``` + +--- + +## Chunk 6: Frontend — ProjectDrawer & TaskModal Integration + +### Task 19: Add Gitea repo selector to ProjectDrawer + +**Files:** +- Modify: `frontend/components/project/ProjectDrawer.vue` + +- [ ] **Step 1: Add Gitea repo selection** + +Add to the template, after the ColorPicker section (around line 34), before the submit button: + +```vue +
+ +
+``` + +Add to script: + +```typescript +import { useGiteaService } from '~/services/gitea' +import type { GiteaRepository } from '~/services/dto/gitea' + +const { listRepositories } = useGiteaService() +const giteaRepos = ref([]) + +const giteaRepoOptions = computed(() => + giteaRepos.value.map(r => ({ label: r.fullName, value: r.fullName })) +) +``` + +Add `giteaRepoFullName` to the form reactive: + +```typescript +giteaRepoFullName: null as string | null, +``` + +In the watch that populates the form, add: + +```typescript +form.giteaRepoFullName = props.project?.giteaOwner && props.project?.giteaRepo + ? `${props.project.giteaOwner}/${props.project.giteaRepo}` + : null +``` + +In `handleSubmit`, parse the fullName and add to payload: + +```typescript +if (form.giteaRepoFullName) { + const [owner, repo] = form.giteaRepoFullName.split('/') + payload.giteaOwner = owner + payload.giteaRepo = repo +} else { + payload.giteaOwner = null + payload.giteaRepo = null +} +``` + +Load repos on mount: + +```typescript +onMounted(async () => { + try { + giteaRepos.value = await listRepositories() + } catch { + // Gitea not configured, ignore + } +}) +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/project/ProjectDrawer.vue +git commit -m "feat : add Gitea repo selector to ProjectDrawer" +``` + +### Task 20: Create TaskGitSection component + +**Files:** +- Create: `frontend/components/task/TaskGitSection.vue` + +- [ ] **Step 1: Create TaskGitSection** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/task/TaskGitSection.vue +git commit -m "feat : add TaskGitSection component" +``` + +### Task 21: Integrate TaskGitSection into TaskModal + +**Files:** +- Modify: `frontend/components/task/TaskModal.vue` + +- [ ] **Step 1: Add TaskGitSection to TaskModal** + +Add the import in the script section: + +```typescript +import type { GiteaSettings } from '~/services/dto/gitea' +import { useGiteaService } from '~/services/gitea' +``` + +Add reactive state: + +```typescript +const giteaUrl = ref('') + +const { getSettings: getGiteaSettings } = useGiteaService() +``` + +Add a computed to check if project has gitea configured: + +```typescript +const hasGitea = computed(() => { + return !!props.task?.project?.giteaOwner && !!props.task?.project?.giteaRepo && !!giteaUrl.value +}) +``` + +Load gitea URL on mount (only if project has gitea configured): + +```typescript +onMounted(async () => { + if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo) { + try { + const settings = await getGiteaSettings() + giteaUrl.value = settings.url ?? '' + } catch { + // Gitea not available + } + } +}) +``` + +Add the component in the template, between the Description section and the Footer (after line 121, before line 123): + +```vue + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/task/TaskModal.vue +git commit -m "feat : integrate TaskGitSection into TaskModal" +``` + +--- + +## Chunk 7: Final Wiring & Verification + +### Task 22: Generate encryption key and add to Docker env + +**Files:** +- Modify: `docker/.env.docker.local` (NOT committed — secrets only) +- Modify: `.env` + +- [ ] **Step 1: Generate a sodium key** + +```bash +php -r "echo sodium_bin2hex(sodium_crypto_secretbox_keygen());" +``` + +- [ ] **Step 2: Add generated key to `docker/.env.docker.local`** + +This file is a local override and must NOT be committed to version control: + +``` +GITEA_ENCRYPTION_KEY= +``` + +- [ ] **Step 3: Add placeholder to `.env` (committed)** + +``` +GITEA_ENCRYPTION_KEY= +``` + +Note: The `TokenEncryptor` will throw a clear error if this is empty, guiding the developer to set the real value. + +- [ ] **Step 4: Commit only `.env`** + +```bash +git add .env +git commit -m "feat : add GITEA_ENCRYPTION_KEY placeholder to .env" +``` + +### Task 23: Verify the full stack + +- [ ] **Step 1: Run migrations** + +```bash +make migration-migrate +``` + +- [ ] **Step 2: Start Nuxt dev server** + +```bash +make dev-nuxt +``` + +- [ ] **Step 3: Verify admin Gitea tab** + +Navigate to the admin page and verify the Gitea tab appears with URL/token fields and test button. + +- [ ] **Step 4: Verify ProjectDrawer** + +Open a project drawer and verify the Gitea repo selector appears (if Gitea is configured). + +- [ ] **Step 5: Verify TaskModal** + +Open a task modal for a project with a Gitea repo configured. Verify the Git section appears with create branch button and info display. + +- [ ] **Step 6: Fix PHP CS** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 7: Final commit if needed** + +```bash +git add -A +git commit -m "fix : PHP CS Fixer adjustments for Gitea integration" +``` diff --git a/docs/superpowers/plans/2026-03-13-my-tasks-page.md b/docs/superpowers/plans/2026-03-13-my-tasks-page.md new file mode 100644 index 0000000..d1be129 --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-my-tasks-page.md @@ -0,0 +1,584 @@ +# My Tasks Page Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a "/my-tasks" page that displays all non-archived tasks across projects with Kanban and List views, filtered by current user by default. + +**Architecture:** Backend: add SearchFilter annotations on Task entity for server-side filtering. Frontend: new page with filter bar + two view modes (Kanban/List), reusing existing TaskCard and TaskModal components. + +**Tech Stack:** PHP 8.4 / API Platform 4 (SearchFilter), Nuxt 4 / Vue 3, Tailwind CSS, MalioSelect, Pinia + +**Spec:** `docs/superpowers/specs/2026-03-13-my-tasks-page-design.md` + +--- + +## Chunk 1: Backend — API Filters + +### Task 1: Add SearchFilter annotations on Task entity + +**Files:** +- Modify: `src/Entity/Task.php:35` (ApiFilter line) + +- [ ] **Step 1: Add new SearchFilter properties** + +In `src/Entity/Task.php`, replace the existing `#[ApiFilter(SearchFilter::class, ...)]` line (line 35) with an expanded version that includes `assignee`, `priority`, `effort`, `tags`, and `status`: + +```php +#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])] +``` + +- [ ] **Step 2: Disable pagination on GetCollection** + +In `src/Entity/Task.php`, modify the `GetCollection` operation (line 25) to disable pagination: + +```php +new GetCollection(paginationEnabled: false), +``` + +- [ ] **Step 3: Verify filters work** + +Run in the container: +```bash +docker exec -t php-lesstime-fpm php bin/console debug:router | grep tasks +``` +Then test the API call: +```bash +curl -s 'http://localhost:8082/api/tasks?archived=false&assignee=/api/users/1' -H 'Cookie: BEARER=...' | head -c 500 +``` +Expected: JSON response with filtered tasks. + +- [ ] **Step 4: Commit** + +```bash +git add src/Entity/Task.php +git commit -m "feat(backend) : add SearchFilter for assignee, priority, effort, tags, status on Task" +``` + +--- + +## Chunk 2: Frontend — Service, i18n, Sidebar + +### Task 2: Add `getFiltered` method to task service + +**Files:** +- Modify: `frontend/services/tasks.ts` + +- [ ] **Step 1: Add the `getFiltered` method** + +Add after the `getByProjectArchived` method (after line 27) in `frontend/services/tasks.ts`: + +```typescript +async function getFiltered(params: Record): Promise { + const data = await api.get>('/tasks', params as Record) + return extractHydraMembers(data) +} +``` + +- [ ] **Step 2: Export the new method** + +Update the return statement (line 47) to include `getFiltered`: + +```typescript +return { getAll, getByProject, getByProjectArchived, getFiltered, create, update, remove } +``` + +- [ ] **Step 3: Commit** + +```bash +git add frontend/services/tasks.ts +git commit -m "feat(frontend) : add getFiltered method to task service" +``` + +### Task 3: Add i18n translations + +**Files:** +- Modify: `frontend/i18n/locales/fr.json` + +- [ ] **Step 1: Add myTasks and sidebar keys** + +Add these entries to `frontend/i18n/locales/fr.json` (before the closing `}`): + +```json +"myTasks": { + "title": "Mes tâches", + "viewKanban": "Vue Kanban", + "viewList": "Vue Liste", + "allProjects": "Tous les projets", + "allGroups": "Tous les groupes", + "allTypes": "Tous les types", + "allPriorities": "Toutes les priorités", + "allEfforts": "Tous les efforts", + "allAssignees": "Tous", + "noTasks": "Aucune tâche", + "backlog": "Backlog" +}, +"sidebar": { + "myTasks": "Mes tâches" +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(frontend) : add i18n translations for my-tasks page" +``` + +### Task 4: Add sidebar navigation link + +**Files:** +- Modify: `frontend/layouts/default.vue:23-35` (nav section) + +- [ ] **Step 1: Add SidebarLink for "Mes tâches"** + +In `frontend/layouts/default.vue`, add a new `SidebarLink` between the "Tableau de bord" link (line 29) and the "Projets" link (line 30): + +```vue + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/layouts/default.vue +git commit -m "feat(frontend) : add Mes tâches link to sidebar navigation" +``` + +--- + +## Chunk 3: Frontend — My Tasks Page (Kanban + List views) + +### Task 5: Create the my-tasks page + +**Files:** +- Create: `frontend/pages/my-tasks.vue` + +- [ ] **Step 1: Create the page file with imports and data loading** + +Create `frontend/pages/my-tasks.vue` with the full page implementation. The page structure: + +**Script section** — data loading pattern (same as `projects/[id]/index.vue`): + +```typescript + +``` + +**Template section:** + +```vue + +``` + +Note: add the `timerStore` and `isTimerOnTask` helper in the script section: + +```typescript +const timerStore = useTimerStore() + +function isTimerOnTask(task: Task): boolean { + const entry = timerStore.activeEntry + if (!entry?.task) return false + const entryTaskId = typeof entry.task === 'string' + ? entry.task + : (entry.task['@id'] ?? entry.task.id) + const taskId = task['@id'] ?? task.id + return entryTaskId === taskId || entryTaskId === `/api/tasks/${task.id}` +} +``` + +- [ ] **Step 2: Verify the page loads** + +Run: `make dev-nuxt` + +Navigate to `http://localhost:3002/my-tasks`. +Expected: page loads with filters and shows tasks assigned to current user in Kanban view. + +- [ ] **Step 3: Test view toggle** + +Click the list icon. Expected: tasks display in list format with title, badges, project code. +Click the kanban icon. Expected: tasks display in columns by status. + +- [ ] **Step 4: Test filters** + +Change the assignee filter to "Tous". Expected: all tasks from all users appear. +Select a specific project. Expected: only tasks from that project appear. +Reset all filters. Expected: all non-archived tasks appear. + +- [ ] **Step 5: Test TaskModal integration** + +Click on a task card/row. Expected: TaskModal opens with task details pre-filled. +Edit and save. Expected: modal closes, tasks reload with updated data. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/pages/my-tasks.vue +git commit -m "feat(frontend) : add my-tasks page with Kanban and List views" +``` diff --git a/docs/superpowers/plans/2026-03-15-bookstack-connector.md b/docs/superpowers/plans/2026-03-15-bookstack-connector.md new file mode 100644 index 0000000..a13bcae --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-bookstack-connector.md @@ -0,0 +1,2148 @@ +# BookStack Connector Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a BookStack connector that lets users link wiki pages and books to tasks, with project-level shelf configuration and admin settings. + +**Architecture:** Mirrors the existing Gitea connector pattern — singleton config entity, API service, API Platform DTOs with Provider/Processor, frontend service + components. Links stored in a dedicated join table `task_bookstack_link`. + +**Tech Stack:** PHP 8.4 / Symfony 8 / API Platform 4 / Doctrine ORM / PostgreSQL 16 / Nuxt 4 / Vue 3 / TypeScript + +**Spec:** `docs/superpowers/specs/2026-03-15-bookstack-connector-design.md` + +--- + +## Chunk 1: Prerequisites & Backend Foundation + +### Task 1: Rename GITEA_ENCRYPTION_KEY → ENCRYPTION_KEY + +**Files:** +- Modify: `src/Service/TokenEncryptor.php` +- Modify: `.env` + +- [ ] **Step 1: Update TokenEncryptor to use generic env var** + +In `src/Service/TokenEncryptor.php`, change the `#[Autowire]` attribute and error message: + +```php +// Change line 19: +#[Autowire('%env(GITEA_ENCRYPTION_KEY)%')] +// To: +#[Autowire('%env(ENCRYPTION_KEY)%')] +``` + +And update the `assertConfigured()` error message: + +```php +// Change: +throw new GiteaApiException('Gitea encryption is not configured. Please set a valid GITEA_ENCRYPTION_KEY.'); +// To: +throw new \RuntimeException('Encryption is not configured. Please set a valid ENCRYPTION_KEY.'); +``` + +Also update the `use` statement: replace `use App\Exception\GiteaApiException;` with `use RuntimeException;`. + +> **Note:** `docker/.env.docker` does not contain `GITEA_ENCRYPTION_KEY` (it may be in `docker/.env.docker.local` which is gitignored). Developers using `.env.docker.local` should update it manually. + +- [ ] **Step 2: Update .env** + +``` +# Change: +GITEA_ENCRYPTION_KEY=aaaaaaaaa +# To: +ENCRYPTION_KEY=aaaaaaaaa +``` + +- [ ] **Step 3: Verify app still works** + +Run: `make cache-clear` +Expected: No errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/Service/TokenEncryptor.php .env +git commit -m "refactor : rename GITEA_ENCRYPTION_KEY to ENCRYPTION_KEY" +``` + +--- + +### Task 2: BookStackConfiguration Entity + +**Files:** +- Create: `src/Entity/BookStackConfiguration.php` +- Create: `src/Repository/BookStackConfigurationRepository.php` + +- [ ] **Step 1: Create BookStackConfiguration entity** + +```php +id; + } + + public function getUrl(): ?string + { + return $this->url; + } + + public function setUrl(?string $url): static + { + $this->url = $url; + + return $this; + } + + public function getEncryptedTokenId(): ?string + { + return $this->encryptedTokenId; + } + + public function setEncryptedTokenId(?string $encryptedTokenId): static + { + $this->encryptedTokenId = $encryptedTokenId; + + return $this; + } + + public function getEncryptedTokenSecret(): ?string + { + return $this->encryptedTokenSecret; + } + + public function setEncryptedTokenSecret(?string $encryptedTokenSecret): static + { + $this->encryptedTokenSecret = $encryptedTokenSecret; + + return $this; + } + + public function hasToken(): bool + { + return null !== $this->encryptedTokenId && null !== $this->encryptedTokenSecret; + } +} +``` + +- [ ] **Step 2: Create BookStackConfigurationRepository** + +```php +findOneBy([]); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/BookStackConfiguration.php src/Repository/BookStackConfigurationRepository.php +git commit -m "feat(bookstack) : add BookStackConfiguration entity and repository" +``` + +--- + +### Task 3: TaskBookStackLink Entity + +**Files:** +- Create: `src/Entity/TaskBookStackLink.php` +- Create: `src/Repository/TaskBookStackLinkRepository.php` + +- [ ] **Step 1: Create TaskBookStackLink entity** + +```php +createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTask(): Task + { + return $this->task; + } + + public function setTask(Task $task): static + { + $this->task = $task; + + return $this; + } + + public function getBookstackId(): int + { + return $this->bookstackId; + } + + public function setBookstackId(int $bookstackId): static + { + $this->bookstackId = $bookstackId; + + return $this; + } + + public function getBookstackType(): string + { + return $this->bookstackType; + } + + public function setBookstackType(string $bookstackType): static + { + $this->bookstackType = $bookstackType; + + return $this; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + + public function getUrl(): string + { + return $this->url; + } + + public function setUrl(string $url): static + { + $this->url = $url; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } +} +``` + +- [ ] **Step 2: Create TaskBookStackLinkRepository** + +```php +findBy(['task' => $taskId], ['createdAt' => 'DESC']); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/TaskBookStackLink.php src/Repository/TaskBookStackLinkRepository.php +git commit -m "feat(bookstack) : add TaskBookStackLink entity and repository" +``` + +--- + +### Task 4: Extend Project Entity + +**Files:** +- Modify: `src/Entity/Project.php` + +- [ ] **Step 1: Add BookStack fields to Project** + +After the `giteaRepo` property (around line 76), add: + +```php + #[ORM\Column(nullable: true)] + #[Groups(['project:read', 'project:write', 'task:read'])] + private ?int $bookstackShelfId = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['project:read', 'project:write'])] + private ?string $bookstackShelfName = null; +``` + +At the end of the class (before the closing `}`), add getters and setters: + +```php + public function getBookstackShelfId(): ?int + { + return $this->bookstackShelfId; + } + + public function setBookstackShelfId(?int $bookstackShelfId): static + { + $this->bookstackShelfId = $bookstackShelfId; + + return $this; + } + + public function getBookstackShelfName(): ?string + { + return $this->bookstackShelfName; + } + + public function setBookstackShelfName(?string $bookstackShelfName): static + { + $this->bookstackShelfName = $bookstackShelfName; + + return $this; + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Entity/Project.php +git commit -m "feat(bookstack) : add bookstackShelfId and bookstackShelfName to Project" +``` + +--- + +### Task 5: Generate and Run Migration + +**Files:** +- Create: `migrations/VersionXXXX.php` (auto-generated) + +- [ ] **Step 1: Generate migration** + +Run inside the PHP container: + +```bash +make shell +# Then inside the container: +php bin/console doctrine:migrations:diff +``` + +Verify the generated migration contains: +- `CREATE TABLE bookstack_configuration` with `id`, `url`, `encrypted_token_id`, `encrypted_token_secret` +- `CREATE TABLE task_bookstack_link` with `id`, `task_id`, `bookstack_id`, `bookstack_type`, `title`, `url`, `created_at` +- Index on `task_bookstack_link.task_id` +- Unique constraint on `(task_id, bookstack_id, bookstack_type)` +- `ALTER TABLE project ADD bookstack_shelf_id`, `bookstack_shelf_name` + +- [ ] **Step 2: Run migration** + +```bash +make migration-migrate +``` + +Expected: Migration executes successfully. + +- [ ] **Step 3: Commit** + +```bash +git add migrations/ +git commit -m "feat(bookstack) : add migration for BookStack tables and Project columns" +``` + +--- + +### Task 6: BookStackApiException + +**Files:** +- Create: `src/Exception/BookStackApiException.php` + +- [ ] **Step 1: Create exception class** + +```php + */ + private array $shelfBookCache = []; + + public function __construct( + private readonly HttpClientInterface $httpClient, + private readonly BookStackConfigurationRepository $configRepository, + private readonly TokenEncryptor $tokenEncryptor, + ) {} + + public function testConnection(): bool + { + try { + $this->request('GET', '/api/docs.json'); + + return true; + } catch (BookStackApiException) { + return false; + } + } + + /** + * @return array + */ + public function listShelves(): array + { + $result = []; + $offset = 0; + $count = 100; + + do { + $data = $this->request('GET', '/api/shelves', [ + 'query' => ['count' => $count, 'offset' => $offset], + ]); + $items = $data['data'] ?? []; + $result = array_merge($result, $items); + $offset += $count; + } while (!empty($items) && $count === count($items)); + + return $result; + } + + /** + * Search for pages and books within a specific shelf. + * + * Algorithm: + * 1. Fetch the shelf's book IDs + * 2. Run two search queries (one for pages, one for books) + * 3. Filter results: pages must belong to a book on the shelf, books must be on the shelf + * + * @return array + */ + public function searchInShelf(int $shelfId, string $query): array + { + $bookIds = $this->getShelfBookIds($shelfId); + + if (empty($bookIds)) { + return []; + } + + $config = $this->getConfiguration(); + $baseUrl = rtrim($config->getUrl() ?? '', '/'); + $trimmed = trim($query); + + // BookStack search API accepts {type:X} for one type at a time — run two queries + $pageResults = $this->request('GET', '/api/search', [ + 'query' => ['query' => $trimmed . ' {type:page}', 'count' => 50], + ]); + $bookResults = $this->request('GET', '/api/search', [ + 'query' => ['query' => $trimmed . ' {type:book}', 'count' => 50], + ]); + + $allResults = array_merge($pageResults['data'] ?? [], $bookResults['data'] ?? []); + + // Build a map of bookId → bookSlug for URL construction + $shelfData = $this->request('GET', sprintf('/api/shelves/%d', $shelfId)); + $bookSlugs = []; + foreach ($shelfData['books'] ?? [] as $book) { + $bookSlugs[$book['id']] = $book['slug'] ?? ''; + } + + $filtered = []; + foreach ($allResults as $item) { + $type = $item['type'] ?? ''; + + if ('page' === $type) { + $bookId = $item['book_id'] ?? 0; + if (in_array($bookId, $bookIds, true)) { + $bookSlug = $bookSlugs[$bookId] ?? ''; + $filtered[] = [ + 'id' => $item['id'], + 'type' => 'page', + 'name' => $item['name'] ?? '', + 'url' => $baseUrl . '/books/' . $bookSlug . '/page/' . $item['slug'], + ]; + } + } elseif ('book' === $type) { + if (in_array($item['id'], $bookIds, true)) { + $filtered[] = [ + 'id' => $item['id'], + 'type' => 'book', + 'name' => $item['name'] ?? '', + 'url' => $baseUrl . '/books/' . $item['slug'], + ]; + } + } + // Ignore chapter and bookshelf types + } + + return $filtered; + } + + /** + * @return array{id: int, name: string, slug: string} + */ + public function getPage(int $id): array + { + return $this->request('GET', sprintf('/api/pages/%d', $id)); + } + + /** + * @return array{id: int, name: string, slug: string} + */ + public function getBook(int $id): array + { + return $this->request('GET', sprintf('/api/books/%d', $id)); + } + + /** + * @return int[] + */ + private function getShelfBookIds(int $shelfId): array + { + if (isset($this->shelfBookCache[$shelfId])) { + return $this->shelfBookCache[$shelfId]; + } + + $data = $this->request('GET', sprintf('/api/shelves/%d', $shelfId)); + $books = $data['books'] ?? []; + + $ids = array_map(static fn (array $book): int => $book['id'], $books); + $this->shelfBookCache[$shelfId] = $ids; + + return $ids; + } + + private function getConfiguration(): BookStackConfiguration + { + $config = $this->configRepository->findSingleton(); + if (null === $config) { + throw new BookStackApiException('BookStack is not configured.'); + } + + return $config; + } + + /** + * @return array{tokenId: string, tokenSecret: string} + */ + private function getDecryptedTokens(BookStackConfiguration $config): array + { + $encryptedId = $config->getEncryptedTokenId(); + $encryptedSecret = $config->getEncryptedTokenSecret(); + + if (null === $encryptedId || null === $encryptedSecret) { + throw new BookStackApiException('BookStack tokens are not set.'); + } + + try { + return [ + 'tokenId' => $this->tokenEncryptor->decrypt($encryptedId), + 'tokenSecret' => $this->tokenEncryptor->decrypt($encryptedSecret), + ]; + } catch (Throwable $e) { + throw new BookStackApiException('Failed to decrypt BookStack tokens: ' . $e->getMessage(), 0, $e); + } + } + + private function extractError(HttpExceptionInterface $e): string + { + try { + $body = $e->getResponse()->getContent(false); + $data = json_decode($body, true); + + if (is_array($data)) { + return $data['message'] ?? $data['error'] ?? $body; + } + + return $body ?: 'Unknown BookStack error'; + } catch (ExceptionInterface) { + return 'BookStack API error (HTTP ' . $e->getResponse()->getStatusCode() . ')'; + } + } + + /** + * @param array $options + */ + private function request(string $method, string $path, array $options = []): array + { + $config = $this->getConfiguration(); + $tokens = $this->getDecryptedTokens($config); + + $options['headers'] = array_merge($options['headers'] ?? [], [ + 'Authorization' => sprintf('Token %s:%s', $tokens['tokenId'], $tokens['tokenSecret']), + 'Accept' => 'application/json', + ]); + $options['timeout'] = 10; + + try { + $response = $this->httpClient->request($method, rtrim($config->getUrl(), '/') . $path, $options); + + return $response->toArray(); + } catch (HttpExceptionInterface $e) { + $message = $this->extractError($e); + throw new BookStackApiException($message, $e->getResponse()->getStatusCode(), $e); + } catch (ExceptionInterface $e) { + throw new BookStackApiException('BookStack API error: ' . $e->getMessage(), 0, $e); + } + } +} +``` + +- [ ] **Step 2: Verify no syntax errors** + +Run: `make cache-clear` +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/Service/BookStackApiService.php +git commit -m "feat(bookstack) : add BookStackApiService" +``` + +--- + +## Chunk 2: API Resources & State Providers/Processors + +### Task 8: BookStackSettings API Resource + +**Files:** +- Create: `src/ApiResource/BookStackSettings.php` +- Create: `src/State/BookStackSettingsProvider.php` +- Create: `src/State/BookStackSettingsProcessor.php` + +- [ ] **Step 1: Create BookStackSettings DTO** + +```php + ['bookstack_settings:read']], + provider: BookStackSettingsProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + new Put( + uriTemplate: '/settings/bookstack', + denormalizationContext: ['groups' => ['bookstack_settings:write']], + normalizationContext: ['groups' => ['bookstack_settings:read']], + provider: BookStackSettingsProvider::class, + processor: BookStackSettingsProcessor::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class BookStackSettings +{ + #[Groups(['bookstack_settings:read', 'bookstack_settings:write'])] + public ?string $url = null; + + #[Groups(['bookstack_settings:write'])] + public ?string $tokenId = null; + + #[Groups(['bookstack_settings:write'])] + public ?string $tokenSecret = null; + + #[Groups(['bookstack_settings:read'])] + public bool $hasToken = false; +} +``` + +- [ ] **Step 2: Create BookStackSettingsProvider** + +```php +configRepository->findSingleton(); + $dto = new BookStackSettings(); + + if (null !== $config) { + $dto->url = $config->getUrl(); + $dto->hasToken = $config->hasToken(); + } + + return $dto; + } +} +``` + +- [ ] **Step 3: Create BookStackSettingsProcessor** + +```php +configRepository->findSingleton(); + if (null === $config) { + $config = new BookStackConfiguration(); + } + + $config->setUrl($data->url); + + if (null !== $data->tokenId && '' !== $data->tokenId + && null !== $data->tokenSecret && '' !== $data->tokenSecret) { + $config->setEncryptedTokenId($this->tokenEncryptor->encrypt($data->tokenId)); + $config->setEncryptedTokenSecret($this->tokenEncryptor->encrypt($data->tokenSecret)); + } + + $this->em->persist($config); + $this->em->flush(); + + $result = new BookStackSettings(); + $result->url = $config->getUrl(); + $result->hasToken = $config->hasToken(); + + return $result; + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/ApiResource/BookStackSettings.php src/State/BookStackSettingsProvider.php src/State/BookStackSettingsProcessor.php +git commit -m "feat(bookstack) : add BookStackSettings API resource with provider and processor" +``` + +--- + +### Task 9: BookStackTestConnection API Resource + +**Files:** +- Create: `src/ApiResource/BookStackTestConnection.php` +- Create: `src/State/BookStackTestConnectionProvider.php` + +- [ ] **Step 1: Create BookStackTestConnection DTO** + +```php + ['bookstack_test:read']], + provider: BookStackTestConnectionProvider::class, + processor: BookStackTestConnectionProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class BookStackTestConnection +{ + #[Groups(['bookstack_test:read'])] + public bool $success = false; +} +``` + +- [ ] **Step 2: Create BookStackTestConnectionProvider** + +```php +success = $this->bookStackApiService->testConnection(); + + return $result; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/ApiResource/BookStackTestConnection.php src/State/BookStackTestConnectionProvider.php +git commit -m "feat(bookstack) : add BookStackTestConnection API resource" +``` + +--- + +### Task 10: BookStackShelf API Resource + +**Files:** +- Create: `src/ApiResource/BookStackShelf.php` +- Create: `src/State/BookStackShelfProvider.php` + +- [ ] **Step 1: Create BookStackShelf DTO** + +```php + ['bookstack_shelf:read']], + provider: BookStackShelfProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class BookStackShelf +{ + #[Groups(['bookstack_shelf:read'])] + public int $id = 0; + + #[Groups(['bookstack_shelf:read'])] + public string $name = ''; +} +``` + +- [ ] **Step 2: Create BookStackShelfProvider** + +```php +bookStackApiService->listShelves(); + } catch (BookStackApiException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + + return array_map(static function (array $shelf): BookStackShelf { + $dto = new BookStackShelf(); + $dto->id = $shelf['id'] ?? 0; + $dto->name = $shelf['name'] ?? ''; + + return $dto; + }, $shelves); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/ApiResource/BookStackShelf.php src/State/BookStackShelfProvider.php +git commit -m "feat(bookstack) : add BookStackShelf API resource for listing shelves" +``` + +--- + +### Task 11: BookStackLink API Resource (CRUD) + +**Files:** +- Create: `src/ApiResource/BookStackLink.php` +- Create: `src/State/BookStackLinkProvider.php` +- Create: `src/State/BookStackLinkProcessor.php` + +- [ ] **Step 1: Create BookStackLink DTO** + +```php + ['bookstack_link:read']], + provider: BookStackLinkProvider::class, + security: "is_granted('IS_AUTHENTICATED_FULLY')", + ), + new Post( + uriTemplate: '/tasks/{taskId}/bookstack/links', + denormalizationContext: ['groups' => ['bookstack_link:write']], + normalizationContext: ['groups' => ['bookstack_link:read']], + provider: BookStackLinkProvider::class, + processor: BookStackLinkProcessor::class, + security: "is_granted('IS_AUTHENTICATED_FULLY')", + ), + new Delete( + uriTemplate: '/tasks/{taskId}/bookstack/links/{id}', + provider: BookStackLinkProvider::class, + processor: BookStackLinkProcessor::class, + security: "is_granted('IS_AUTHENTICATED_FULLY')", + ), + ], +)] +final class BookStackLink +{ + #[Groups(['bookstack_link:read'])] + public ?int $id = null; + + #[Groups(['bookstack_link:read', 'bookstack_link:write'])] + public int $bookstackId = 0; + + #[Groups(['bookstack_link:read', 'bookstack_link:write'])] + public string $bookstackType = ''; + + #[Groups(['bookstack_link:read', 'bookstack_link:write'])] + public string $title = ''; + + #[Groups(['bookstack_link:read', 'bookstack_link:write'])] + public string $url = ''; + + #[Groups(['bookstack_link:read'])] + public ?string $createdAt = null; +} +``` + +- [ ] **Step 2: Create BookStackLinkProvider** + +```php +linkRepository->find($uriVariables['id'] ?? 0); + if (null === $link) { + throw new NotFoundHttpException('Link not found.'); + } + $dto = new BookStackLink(); + $dto->id = $link->getId(); + + return $dto; + } + + $taskId = $uriVariables['taskId'] ?? 0; + $links = $this->linkRepository->findByTaskId($taskId); + + return array_map(static function (\App\Entity\TaskBookStackLink $link): BookStackLink { + $dto = new BookStackLink(); + $dto->id = $link->getId(); + $dto->bookstackId = $link->getBookstackId(); + $dto->bookstackType = $link->getBookstackType(); + $dto->title = $link->getTitle(); + $dto->url = $link->getUrl(); + $dto->createdAt = $link->getCreatedAt()->format('c'); + + return $dto; + }, $links); + } +} +``` + +- [ ] **Step 3: Create BookStackLinkProcessor** + +```php +handleDelete($uriVariables); + } + + return $this->handleCreate($data, $uriVariables); + } + + private function handleCreate(mixed $data, array $uriVariables): BookStackLink + { + assert($data instanceof BookStackLink); + + $taskId = $uriVariables['taskId'] ?? 0; + $task = $this->em->getRepository(Task::class)->find($taskId); + + if (null === $task) { + throw new NotFoundHttpException('Task not found.'); + } + + $link = new TaskBookStackLink(); + $link->setTask($task); + $link->setBookstackId($data->bookstackId); + $link->setBookstackType($data->bookstackType); + $link->setTitle($data->title); + $link->setUrl($data->url); + + $this->em->persist($link); + $this->em->flush(); + + $result = new BookStackLink(); + $result->id = $link->getId(); + $result->bookstackId = $link->getBookstackId(); + $result->bookstackType = $link->getBookstackType(); + $result->title = $link->getTitle(); + $result->url = $link->getUrl(); + $result->createdAt = $link->getCreatedAt()->format('c'); + + return $result; + } + + private function handleDelete(array $uriVariables): null + { + $linkId = $uriVariables['id'] ?? 0; + $link = $this->linkRepository->find($linkId); + + if (null === $link) { + throw new NotFoundHttpException('Link not found.'); + } + + $this->em->remove($link); + $this->em->flush(); + + return null; + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/ApiResource/BookStackLink.php src/State/BookStackLinkProvider.php src/State/BookStackLinkProcessor.php +git commit -m "feat(bookstack) : add BookStackLink API resource with CRUD operations" +``` + +--- + +### Task 12: BookStackSearchResult API Resource + +**Files:** +- Create: `src/ApiResource/BookStackSearchResult.php` +- Create: `src/State/BookStackSearchResultProvider.php` + +- [ ] **Step 1: Create BookStackSearchResult DTO** + +```php + ['bookstack_search:read']], + provider: BookStackSearchResultProvider::class, + security: "is_granted('IS_AUTHENTICATED_FULLY')", + ), + ], +)] +final class BookStackSearchResult +{ + #[Groups(['bookstack_search:read'])] + public int $id = 0; + + #[Groups(['bookstack_search:read'])] + public string $type = ''; + + #[Groups(['bookstack_search:read'])] + public string $name = ''; + + #[Groups(['bookstack_search:read'])] + public string $url = ''; +} +``` + +- [ ] **Step 2: Create BookStackSearchResultProvider** + +The provider reads the `q` query parameter from the request, resolves the task's project shelf, and calls the API service. + +```php +em->getRepository(Task::class)->find($taskId); + + if (null === $task || null === $task->getProject()) { + return []; + } + + $shelfId = $task->getProject()->getBookstackShelfId(); + if (null === $shelfId) { + return []; + } + + $request = $this->requestStack->getCurrentRequest(); + $query = $request?->query->get('q', '') ?? ''; + + if ('' === trim($query)) { + return []; + } + + try { + $results = $this->bookStackApiService->searchInShelf($shelfId, $query); + } catch (BookStackApiException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + + return array_map(static function (array $item): BookStackSearchResult { + $dto = new BookStackSearchResult(); + $dto->id = $item['id']; + $dto->type = $item['type']; + $dto->name = $item['name']; + $dto->url = $item['url']; + + return $dto; + }, $results); + } +} +``` + +- [ ] **Step 3: Verify cache clear passes** + +Run: `make cache-clear` +Expected: No errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/ApiResource/BookStackSearchResult.php src/State/BookStackSearchResultProvider.php +git commit -m "feat(bookstack) : add BookStackSearchResult API resource for shelf-scoped search" +``` + +--- + +## Chunk 3: Frontend — Service, DTOs, Admin Tab + +### Task 13: Frontend DTOs + +**Files:** +- Create: `frontend/services/dto/bookstack.ts` + +- [ ] **Step 1: Create BookStack DTOs** + +```typescript +export type BookStackSettings = { + url: string | null + hasToken: boolean +} + +export type BookStackSettingsWrite = { + url: string | null + tokenId: string | null + tokenSecret: string | null +} + +export type BookStackTestResult = { + success: boolean +} + +export type BookStackShelf = { + id: number + name: string +} + +export type BookStackLink = { + id: number + bookstackId: number + bookstackType: 'page' | 'book' + title: string + url: string + createdAt: string +} + +export type BookStackLinkCreate = { + bookstackId: number + bookstackType: 'page' | 'book' + title: string + url: string +} + +export type BookStackSearchResult = { + id: number + type: 'page' | 'book' + name: string + url: string +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/services/dto/bookstack.ts +git commit -m "feat(bookstack) : add frontend BookStack DTOs" +``` + +--- + +### Task 14: Frontend BookStack Service + +**Files:** +- Create: `frontend/services/bookstack.ts` + +- [ ] **Step 1: Create the service** + +```typescript +import type { + BookStackSettings, + BookStackSettingsWrite, + BookStackTestResult, + BookStackShelf, + BookStackLink, + BookStackLinkCreate, + BookStackSearchResult, +} from './dto/bookstack' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export function useBookStackService() { + const api = useApi() + + async function getSettings(): Promise { + return api.get('/settings/bookstack') + } + + async function saveSettings(payload: BookStackSettingsWrite): Promise { + return api.put('/settings/bookstack', payload as Record, { + toastSuccessKey: 'bookstack.settings.saved', + }) + } + + async function testConnection(): Promise { + return api.post('/settings/bookstack/test') + } + + async function listShelves(): Promise { + const data = await api.get>('/bookstack/shelves') + return extractHydraMembers(data) + } + + async function getLinks(taskId: number): Promise { + const data = await api.get>(`/tasks/${taskId}/bookstack/links`) + return extractHydraMembers(data) + } + + async function addLink(taskId: number, payload: BookStackLinkCreate): Promise { + return api.post(`/tasks/${taskId}/bookstack/links`, payload as Record) + } + + async function removeLink(taskId: number, linkId: number): Promise { + await api.delete(`/tasks/${taskId}/bookstack/links/${linkId}`) + } + + async function search(taskId: number, query: string): Promise { + const data = await api.get>( + `/tasks/${taskId}/bookstack/search`, + { q: query }, + ) + return extractHydraMembers(data) + } + + return { + getSettings, + saveSettings, + testConnection, + listShelves, + getLinks, + addLink, + removeLink, + search, + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/services/bookstack.ts +git commit -m "feat(bookstack) : add frontend BookStack service" +``` + +--- + +### Task 15: i18n Translations + +**Files:** +- Modify: `frontend/i18n/locales/fr.json` + +- [ ] **Step 1: Add BookStack translations** + +Add the following block inside the root JSON object (after the `"gitea"` block): + +```json + "bookstack": { + "settings": { + "title": "Configuration BookStack", + "url": "URL du serveur", + "urlPlaceholder": "https://wiki.example.com", + "tokenId": "Token ID", + "tokenIdPlaceholder": "Entrez le Token ID", + "tokenSecret": "Token Secret", + "tokenSecretPlaceholder": "Entrez le Token Secret", + "tokenConfigured": "Token configuré", + "save": "Enregistrer", + "saved": "Configuration BookStack sauvegardée.", + "testConnection": "Tester la connexion", + "testSuccess": "Connexion réussie.", + "testFailed": "Connexion échouée." + }, + "links": { + "title": "Documentation", + "searchPlaceholder": "Rechercher une page ou un livre...", + "noResults": "Aucun résultat", + "empty": "Aucun document lié" + } + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(bookstack) : add i18n translations for BookStack" +``` + +--- + +### Task 16: AdminBookStackTab Component + +**Files:** +- Create: `frontend/components/admin/AdminBookStackTab.vue` + +- [ ] **Step 1: Create the admin tab component** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/admin/AdminBookStackTab.vue +git commit -m "feat(bookstack) : add AdminBookStackTab component" +``` + +--- + +### Task 17: Add BookStack Tab to Admin Page + +**Files:** +- Modify: `frontend/pages/admin.vue` + +- [ ] **Step 1: Add the BookStack tab** + +In `frontend/pages/admin.vue`: + +1. In the `tabs` array, add after the gitea entry: + +```typescript + { key: 'bookstack', label: 'BookStack' }, +``` + +2. In the `type TabKey` union, the new key is automatically included since it's derived from `typeof tabs[number]['key']`. + +3. In the template, add after ``: + +```html + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/pages/admin.vue +git commit -m "feat(bookstack) : add BookStack tab to admin page" +``` + +--- + +## Chunk 4: Frontend — Project Drawer, Task Component, Task Modal + +### Task 18: Update ProjectDrawer with Shelf Select + +**Files:** +- Modify: `frontend/components/project/ProjectDrawer.vue` +- Modify: `frontend/services/dto/project.ts` + +- [ ] **Step 1: Update Project DTOs** + +In `frontend/services/dto/project.ts`: + +Add to `Project` type: + +```typescript + bookstackShelfId: number | null + bookstackShelfName: string | null +``` + +Add to `ProjectWrite` type: + +```typescript + bookstackShelfId?: number | null + bookstackShelfName?: string | null +``` + +- [ ] **Step 2: Update ProjectDrawer script** + +In `frontend/components/project/ProjectDrawer.vue`: + +1. Add import at the top of the script: + +```typescript +import type { BookStackShelf } from '~/services/dto/bookstack' +import { useBookStackService } from '~/services/bookstack' +``` + +2. After the Gitea service setup (around line 93-97), add: + +```typescript +const { listShelves } = useBookStackService() +const bookstackShelves = ref([]) + +const bookstackShelfOptions = computed(() => + bookstackShelves.value.map(s => ({ label: s.name, value: s.id })) +) +``` + +3. Add `bookstackShelfId` to the `form` reactive object: + +```typescript + bookstackShelfId: null as number | null, +``` + +4. In the `watch` that populates the form when the drawer opens, add (in the `if (props.project)` branch): + +```typescript + form.bookstackShelfId = props.project.bookstackShelfId ?? null +``` + +And in the `else` branch: + +```typescript + form.bookstackShelfId = null +``` + +5. In `handleSubmit`, after the Gitea payload block (after `payload.giteaRepo = null`), add: + +```typescript + if (form.bookstackShelfId) { + const shelf = bookstackShelves.value.find(s => s.id === form.bookstackShelfId) + payload.bookstackShelfId = form.bookstackShelfId + payload.bookstackShelfName = shelf?.name ?? null + } else { + payload.bookstackShelfId = null + payload.bookstackShelfName = null + } +``` + +6. In `onMounted`, after the Gitea repos loading, add: + +```typescript + try { + bookstackShelves.value = await listShelves() + } catch { + // BookStack not configured, ignore + } +``` + +- [ ] **Step 3: Update ProjectDrawer template** + +After the Gitea repo select `
` (around line 35-43), add: + +```html +
+ +
+``` + +- [ ] **Step 4: Commit** + +```bash +git add frontend/services/dto/project.ts frontend/components/project/ProjectDrawer.vue +git commit -m "feat(bookstack) : add shelf select to ProjectDrawer" +``` + +--- + +### Task 19: TaskBookStackLinks Component + +**Files:** +- Create: `frontend/components/task/TaskBookStackLinks.vue` + +- [ ] **Step 1: Create the component** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/task/TaskBookStackLinks.vue +git commit -m "feat(bookstack) : add TaskBookStackLinks component" +``` + +--- + +### Task 20: Integrate into TaskModal + +**Files:** +- Modify: `frontend/components/task/TaskModal.vue` + +- [ ] **Step 1: Add BookStack section to TaskModal template** + +In `frontend/components/task/TaskModal.vue`, after the `TaskGitSection` block (around line 82-84): + +```html + + +``` + +- [ ] **Step 2: Add hasBookStack computed** + +In the ` +``` + +- [ ] **Commit:** +```bash +git add frontend/components/user/UserDrawer.vue +git commit -m "feat(frontend) : update UserDrawer to support client user creation with client and projects" +``` + +--- + +## Chunk 6: Frontend Services & DTOs + +### Task 19: Create ClientTicket DTO + +- [ ] **Create `frontend/services/dto/client-ticket.ts`** with the following content: +```typescript +import type { TaskDocument } from './task-document' +import type { UserData } from './user-data' + +export type ClientTicketType = 'bug' | 'improvement' | 'other' +export type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected' + +export type ClientTicket = { + '@id'?: string + id: number + number: number + type: ClientTicketType + title: string + description: string + url: string | null + status: ClientTicketStatus + statusComment: string | null + project: string + submittedBy: UserData | null + createdAt: string + updatedAt: string + documents: TaskDocument[] +} + +export type ClientTicketWrite = { + type: ClientTicketType + title: string + description: string + url?: string | null + project: string +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/client-ticket.ts +git commit -m "feat(frontend) : create ClientTicket TypeScript DTOs" +``` + +### Task 20: Create client-tickets service + +- [ ] **Create `frontend/services/client-tickets.ts`** with the following content: +```typescript +import type { ClientTicket, ClientTicketWrite } from './dto/client-ticket' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export function useClientTicketService() { + const api = useApi() + + async function getAll(params?: Record): Promise { + const data = await api.get>('/client_tickets', params) + return extractHydraMembers(data) + } + + async function getById(id: number): Promise { + return await api.get(`/client_tickets/${id}`) + } + + async function create(data: ClientTicketWrite): Promise { + return await api.post('/client_tickets', data as Record, { + toastSuccessKey: 'clientTicket.created', + }) + } + + async function updateStatus(id: number, status: string, statusComment?: string): Promise { + return await api.patch(`/client_tickets/${id}`, { + status, + ...(statusComment ? { statusComment } : {}), + }, { + toastSuccessKey: 'clientTicket.statusUpdated', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/client_tickets/${id}`, {}, { + toastSuccessKey: 'clientTicket.deleted', + }) + } + + return { getAll, getById, create, updateStatus, remove } +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/client-tickets.ts +git commit -m "feat(frontend) : create client-tickets API service" +``` + +### Task 21: Update Task DTO with clientTicket field + +- [ ] **Modify `frontend/services/dto/task.ts`** — Add the import at the top of the file (after line 8): +```typescript +import type { ClientTicket } from './client-ticket' +``` + +- [ ] **Add the `clientTicket` field** to the `Task` type. After the `documents: TaskDocument[]` line (line 23), add: +```typescript + clientTicket?: { id: number; number: number; type: string; status: string; title: string } | null +``` + +- [ ] **Add the `clientTicket` field** to the `TaskWrite` type. After the `tags: string[]` line (line 36), add: +```typescript + clientTicket?: string | null +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/task.ts +git commit -m "feat(frontend) : add clientTicket field to Task DTO" +``` + +### Task 22: Update TaskDocument DTO with clientTicket field + +- [ ] **Modify `frontend/services/dto/task-document.ts`** — Replace the entire content with: +```typescript +import type { UserData } from './user-data' + +export type TaskDocument = { + '@id'?: string + id: number + task: string | null + clientTicket?: string | null + originalName: string + fileName: string + mimeType: string + size: number + createdAt: string + uploadedBy: UserData | null +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/task-document.ts +git commit -m "feat(frontend) : add clientTicket field to TaskDocument DTO" +``` + +### Task 23: Add i18n translations for client portal + +- [ ] **Modify `frontend/i18n/locales/fr.json`** — Add the following keys at the end of the JSON object, before the closing `}`. After the `"bookstack"` block (after line 237), add: +```json + , + "portal": { + "title": "Portail client", + "projects": "Mes projets", + "openTickets": "tickets ouverts", + "noProjects": "Aucun projet disponible.", + "newTicket": "Nouveau ticket", + "ticketDetail": "Détail du ticket" + }, + "clientTicket": { + "title": "Tickets client", + "new": "Nouveau ticket", + "created": "Ticket créé avec succès.", + "deleted": "Ticket supprimé avec succès.", + "statusUpdated": "Statut du ticket mis à jour.", + "type": { + "bug": "Bug", + "improvement": "Amélioration", + "other": "Autre" + }, + "status": { + "new": "Nouveau", + "in_progress": "En cours", + "done": "Terminé", + "rejected": "Rejeté" + }, + "fields": { + "title": "Titre", + "description": "Description", + "url": "URL (page concernée)", + "urlPlaceholder": "https://example.com/page-concernee", + "type": "Type", + "project": "Projet" + }, + "confirmDelete": "Supprimer ce ticket ?", + "rejectComment": "Commentaire de rejet", + "rejectCommentRequired": "Un commentaire est requis pour rejeter un ticket.", + "linkedTooltip": "Lié au ticket client CT-{number}" + } +``` + +- [ ] **Commit:** +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(i18n) : add French translations for client portal and client tickets" +``` diff --git a/docs/superpowers/plans/2026-03-15-client-portal-phase2.md b/docs/superpowers/plans/2026-03-15-client-portal-phase2.md new file mode 100644 index 0000000..badd515 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-client-portal-phase2.md @@ -0,0 +1,1960 @@ +# Client Portal Phase 2 — Portal & UI + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the client-facing portal pages (project list, ticket list, ticket creation with document upload), add client ticket indicators on internal kanban/my-tasks views, and create the admin "Tickets client" tab for managing all tickets. + +**Architecture:** Portal pages live under `/portal/` and use the existing default layout with a simplified sidebar for ROLE_CLIENT users. Auth middleware is extended to redirect ROLE_CLIENT to `/portal` and block internal pages. Client ticket data on internal task views flows through the `task:read` serialization group (no extra API call). Admin tab follows the existing tab pattern in `admin.vue`. + +**Tech Stack:** PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16, Nuxt 4, Vue 3, TypeScript, Tailwind CSS + +**Spec:** `docs/superpowers/specs/2026-03-15-client-portal-design.md` + +**Depends on:** Phase 1 (`docs/superpowers/plans/2026-03-15-client-portal-phase1.md`) + +--- + +## Chunk 1: Auth Middleware & Portal Layout + +### Task 1: Update auth middleware for ROLE_CLIENT routing + +- [ ] **Modify `frontend/middleware/auth.global.ts`** — Add ROLE_CLIENT redirect logic. After the existing login redirect (line 14), add portal routing. Replace the full file with: + +```typescript +export default defineNuxtRouteMiddleware(async (to) => { + const auth = useAuthStore() + const isLogin = to.path === '/login' + + if (!auth.checked) { + await auth.ensureSession() + } + + if (!isLogin && !auth.isAuthenticated) { + return navigateTo('/login') + } + + if (isLogin && auth.isAuthenticated) { + const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false + return navigateTo(isClient ? '/portal' : '/') + } + + // ROLE_CLIENT: redirect to /portal, block internal pages + if (auth.isAuthenticated && auth.user?.roles?.includes('ROLE_CLIENT')) { + const isPortalRoute = to.path.startsWith('/portal') + const isLoginRoute = to.path === '/login' + if (!isPortalRoute && !isLoginRoute) { + return navigateTo('/portal') + } + } +}) +``` + +- [ ] **Commit:** +```bash +git add frontend/middleware/auth.global.ts +git commit -m "feat(auth) : redirect ROLE_CLIENT to /portal and block internal pages" +``` + +### Task 2: Create portal layout + +- [ ] **Create `frontend/layouts/portal.vue`** — Simplified layout for client users with minimal sidebar (logo, portal link, logout): + +```vue + + + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/layouts/portal.vue +git commit -m "feat(portal) : add portal layout with simplified sidebar for client users" +``` + +### Task 3: Add i18n keys for portal and client tickets + +- [ ] **Modify `frontend/i18n/locales/fr.json`** — Add portal and clientTicket sections. After the `"bookstack"` block (before the closing `}`), add: + +```json + "portal": { + "title": "Portail client", + "projects": "Mes projets", + "openTickets": "tickets ouverts", + "noProjects": "Aucun projet disponible.", + "newTicket": "Nouveau ticket", + "ticketDetail": "Détail du ticket", + "backToProject": "Retour au projet", + "submitTicket": "Soumettre le ticket", + "ticketCreated": "Ticket soumis avec succès." + }, + "clientTicket": { + "type": { + "bug": "Bug", + "improvement": "Amélioration", + "other": "Autre" + }, + "status": { + "new": "Nouveau", + "in_progress": "En cours", + "done": "Terminé", + "rejected": "Rejeté" + }, + "title": "Titre", + "description": "Description", + "url": "URL (page concernée)", + "statusComment": "Commentaire de statut", + "created": "Ticket créé", + "statusChanged": "Statut mis à jour", + "confirmDelete": "Supprimer ce ticket ?", + "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce ticket ? Cette action est irréversible.", + "linkedTooltip": "Lié au ticket client {number}", + "rejectionRequired": "Un commentaire est requis pour rejeter un ticket", + "noTickets": "Aucun ticket.", + "allStatuses": "Tous les statuts", + "allProjects": "Tous les projets", + "submittedBy": "Soumis par", + "createdAt": "Créé le", + "deleted": "Ticket supprimé avec succès.", + "statusUpdated": "Statut mis à jour avec succès.", + "adminTab": "Tickets client", + "selectType": "Type de ticket", + "changeStatus": "Changer le statut" + } +``` + +- [ ] **Commit:** +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(i18n) : add portal and client ticket translation keys" +``` + +--- + +## Chunk 2: DTOs & Services + +### Task 4: Create ClientTicket DTO + +- [ ] **Create `frontend/services/dto/client-ticket.ts`** — TypeScript types for client tickets: + +```typescript +import type { TaskDocument } from './task-document' + +export type ClientTicketType = 'bug' | 'improvement' | 'other' +export type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected' + +export type ClientTicket = { + '@id'?: string + id: number + number: number + type: ClientTicketType + title: string + description: string + url: string | null + status: ClientTicketStatus + statusComment: string | null + project: string + submittedBy: string | null + createdAt: string + updatedAt: string + documents?: TaskDocument[] +} + +export type ClientTicketWrite = { + type: ClientTicketType + title: string + description: string + url?: string | null + project: string +} + +export type ClientTicketStatusUpdate = { + status: ClientTicketStatus + statusComment?: string | null +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/client-ticket.ts +git commit -m "feat(dto) : add ClientTicket TypeScript types" +``` + +### Task 5: Create client-tickets service + +- [ ] **Create `frontend/services/client-tickets.ts`** — API service for client tickets following the existing service pattern (`useTaskService`): + +```typescript +import type { ClientTicket, ClientTicketWrite, ClientTicketStatusUpdate } from './dto/client-ticket' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export function useClientTicketService() { + const api = useApi() + + async function getAll(params?: { project?: number; status?: string; submittedBy?: number }): Promise { + const query: Record = {} + if (params?.project) query.project = `/api/projects/${params.project}` + if (params?.status) query.status = params.status + if (params?.submittedBy) query.submittedBy = `/api/users/${params.submittedBy}` + const data = await api.get>('/client_tickets', query) + return extractHydraMembers(data) + } + + async function getById(id: number): Promise { + return api.get(`/client_tickets/${id}`) + } + + async function create(payload: ClientTicketWrite): Promise { + return api.post('/client_tickets', payload as Record, { + toastSuccessKey: 'portal.ticketCreated', + }) + } + + async function updateStatus(id: number, payload: ClientTicketStatusUpdate): Promise { + return api.patch(`/client_tickets/${id}`, payload as Record, { + toastSuccessKey: 'clientTicket.statusUpdated', + }) + } + + async function remove(id: number): Promise { + await api.delete(`/client_tickets/${id}`, {}, { + toastSuccessKey: 'clientTicket.deleted', + }) + } + + return { getAll, getById, create, updateStatus, remove } +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/client-tickets.ts +git commit -m "feat(service) : add client-tickets API service" +``` + +### Task 6: Extend Task DTO with clientTicket field + +- [ ] **Modify `frontend/services/dto/task.ts`** — Add `clientTicket` field to the `Task` type. After the `documents: TaskDocument[]` line (line 23), add: + +```typescript + clientTicket: { + id: number + number: number + type: string + status: string + title: string + } | null +``` + +The full `Task` type should now include `clientTicket` after `documents`: + +```typescript +import type { TaskStatus } from './task-status' +import type { TaskEffort } from './task-effort' +import type { TaskPriority } from './task-priority' +import type { TaskTag } from './task-tag' +import type { TaskGroup } from './task-group' +import type { UserData } from './user-data' +import type { Project } from './project' +import type { TaskDocument } from './task-document' + +export type Task = { + id: number + '@id'?: string + number: number + title: string + description: string | null + status: TaskStatus | null + effort: TaskEffort | null + priority: TaskPriority | null + assignee: UserData | null + group: TaskGroup | null + project: Project | null + tags: TaskTag[] + documents: TaskDocument[] + archived: boolean + clientTicket: { + id: number + number: number + type: string + status: string + title: string + } | null +} + +export type TaskWrite = { + title: string + description: string | null + status: string | null + effort: string | null + priority: string | null + assignee: string | null + group: string | null + project: string + tags: string[] + archived?: boolean +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/task.ts +git commit -m "feat(dto) : add clientTicket field to Task type" +``` + +### Task 7: Update UserData DTO for allowedProjects + +- [ ] **Modify `frontend/services/dto/user-data.ts`** — Add `client` and `allowedProjects` fields for client users. This must happen before portal pages are built because `auth.user.allowedProjects` needs proper typing. Replace the full file with: + +```typescript +import type { Project } from './project' + +export type UserData = { + id: number + '@id'?: string + username: string + roles: string[] + client?: { id: number; name: string } | null + allowedProjects?: Project[] +} + +export type UserWrite = { + username: string + password?: string + roles: string[] + client?: string | null + allowedProjects?: string[] +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/user-data.ts +git commit -m "feat(dto) : add client and allowedProjects fields to UserData type" +``` + +--- + +## Chunk 3: Portal Pages + +### Task 8: Create portal project list page + +- [ ] **Create `frontend/pages/portal/index.vue`** — List of client's allowed projects with open ticket count. Uses the `portal` layout. **Note:** For admin users (ROLE_ADMIN), the page loads all projects via the projects service as a fallback, since admins have no `allowedProjects`: + +```vue + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/pages/portal/index.vue +git commit -m "feat(portal) : add portal project list page" +``` + +### Task 9: Create portal ticket list page + +- [ ] **Create `frontend/pages/portal/projects/[id]/index.vue`** — List of tickets for a project with status badges and ticket detail modal: + +```vue + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/pages/portal/projects/[id]/index.vue +git commit -m "feat(portal) : add ticket list page for a project" +``` + +### Task 10: Create ClientTicketDetailModal component + +- [ ] **Create `frontend/components/client-ticket/ClientTicketDetailModal.vue`** — Read-only modal showing ticket details (title, description, url, status, statusComment, documents). Follows the `TaskModal` pattern for styling: + +```vue + + + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/components/client-ticket/ClientTicketDetailModal.vue +git commit -m "feat(portal) : add client ticket detail modal component" +``` + +### Task 11: Create new ticket form page + +- [ ] **Create `frontend/pages/portal/projects/[id]/new-ticket.vue`** — Ticket creation form with type select, title, description, url (if bug), and document upload: + +```vue + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/pages/portal/projects/[id]/new-ticket.vue +git commit -m "feat(portal) : add new ticket creation form page" +``` + +--- + +## Chunk 4: Document Upload on Tickets + +### Task 12: Generalize TaskDocumentUpload with optional clientTicketId prop + +- [ ] **Modify `frontend/components/task/TaskDocumentUpload.vue`** — Add an optional `clientTicketId` prop as an alternative to `taskId`. Replace the ` +``` + +Also update the template references. Replace: +```vue + +``` + +With: +```vue + +``` + +And replace: +```vue + +``` + +With: +```vue + +``` + +- [ ] **Commit:** +```bash +git add frontend/components/client-ticket/ClientTicketDetailModal.vue +git commit -m "feat(portal) : add document upload to ticket detail modal" +``` + +--- + +## Chunk 5: Client Ticket Icon on Internal Views + +### Task 15: Add client ticket icon to TaskCard + +- [ ] **Modify `frontend/components/task/TaskCard.vue`** — Add a small `heroicons:user-circle` icon next to the task code if `task.clientTicket` is set. In the template, after the `` showing `task.project.code` (line 11), add the icon. Replace: + +```vue + {{ task.project.code }}{{ task.number }} +``` + +With: +```vue +
+ {{ task.project.code }}{{ task.number }} + +
+``` + +- [ ] **Commit:** +```bash +git add frontend/components/task/TaskCard.vue +git commit -m "feat(kanban) : show client ticket icon on task cards linked to a ticket" +``` + +### Task 16: Add client ticket icon to my-tasks list view + +- [ ] **Modify `frontend/pages/my-tasks.vue`** — Add the same `heroicons:user-circle` icon in the list view. In the list view task row, after the task code span (around line 418), add the icon. Replace: + +```vue + + {{ task.project.code }}-{{ task.number }} + +``` + +With: +```vue +
+ + + {{ task.project.code }}-{{ task.number }} + +
+``` + +- [ ] **Commit:** +```bash +git add frontend/pages/my-tasks.vue +git commit -m "feat(my-tasks) : show client ticket icon on tasks linked to a ticket" +``` + +### Task 17: Show client ticket info in TaskModal + +- [ ] **Modify `frontend/components/task/TaskModal.vue`** — Show client ticket link info when editing a task that has `clientTicket` set. In the template, after the header `

` tag (line 27), add a client ticket badge. After the closing `

` of the header flex container (line 29), add: + +```vue + +
+ + + {{ $t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') }) }} + + + {{ $t(`clientTicket.status.${task.clientTicket.status}`) }} + +
+``` + +In the ` + + +``` + +- [ ] **Commit:** +```bash +git add frontend/components/admin/AdminClientTicketTab.vue +git commit -m "feat(admin) : add client tickets tab with list, filters, status change, and delete" +``` + +### Task 19: Register the new tab in admin.vue + +- [ ] **Modify `frontend/pages/admin.vue`** — Add the "Tickets client" tab. In the `tabs` array (line 39), add a new entry after the `bookstack` tab: + +Replace: +```typescript +const tabs = [ + { key: 'clients', label: 'Clients' }, + { key: 'statuses', label: 'Statuts' }, + { key: 'efforts', label: 'Efforts' }, + { key: 'priorities', label: 'Priorités' }, + { key: 'tags', label: 'Tags' }, + { key: 'users', label: 'Utilisateurs' }, + { key: 'gitea', label: 'Gitea' }, + { key: 'bookstack', label: 'BookStack' }, +] as const +``` + +With: +```typescript +const tabs = [ + { key: 'clients', label: 'Clients' }, + { key: 'statuses', label: 'Statuts' }, + { key: 'efforts', label: 'Efforts' }, + { key: 'priorities', label: 'Priorités' }, + { key: 'tags', label: 'Tags' }, + { key: 'users', label: 'Utilisateurs' }, + { key: 'client-tickets', label: 'Tickets client' }, + { key: 'gitea', label: 'Gitea' }, + { key: 'bookstack', label: 'BookStack' }, +] as const +``` + +In the template, after the `AdminBookStackTab` (line 31), add: +```vue + +``` + +- [ ] **Commit:** +```bash +git add frontend/pages/admin.vue +git commit -m "feat(admin) : register client tickets tab in admin page" +``` + +--- + +## Chunk 7: Final Touches + +### Task 20: Update login redirect to portal for client users + +- [ ] **Modify `frontend/pages/login.vue`** — After successful login, redirect ROLE_CLIENT users to `/portal` instead of `/`. The actual login page uses `router.push`, not `navigateTo`. + +Find this line (around line 66): +```typescript + await router.push('/') +``` + +Replace with: +```typescript + const isClient = auth.user?.roles?.includes('ROLE_CLIENT') ?? false + await router.push(isClient ? '/portal' : '/') +``` + +- [ ] **Commit:** +```bash +git add frontend/pages/login.vue +git commit -m "feat(auth) : redirect client users to /portal after login" +``` + +### Task 21: Extract duplicated helpers to composable + +- [ ] **Create `frontend/composables/useClientTicketHelpers.ts`** — Extract the `typeBadgeClass`, `statusBadgeClass`, and `formatDate` functions that are duplicated in `ClientTicketDetailModal.vue`, `portal/projects/[id]/index.vue`, and `AdminClientTicketTab.vue`: + +```typescript +export function useClientTicketHelpers() { + function typeBadgeClass(type: string): string { + switch (type) { + case 'bug': return 'bg-red-500' + case 'improvement': return 'bg-blue-500' + default: return 'bg-neutral-500' + } + } + + function statusBadgeClass(status: string): string { + switch (status) { + case 'new': return 'bg-blue-100 text-blue-700' + case 'in_progress': return 'bg-yellow-100 text-yellow-700' + case 'done': return 'bg-green-100 text-green-700' + case 'rejected': return 'bg-red-100 text-red-700' + default: return 'bg-neutral-100 text-neutral-700' + } + } + + function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + year: 'numeric', + }) + } + + return { typeBadgeClass, statusBadgeClass, formatDate } +} +``` + +- [ ] **Update the 3 components** to import and use the composable instead of local functions: + - `frontend/components/client-ticket/ClientTicketDetailModal.vue` — Remove local `typeBadgeClass`, `statusBadgeClass`, `formatDate` functions and add `const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()` + - `frontend/pages/portal/projects/[id]/index.vue` — Same replacement + - `frontend/components/admin/AdminClientTicketTab.vue` — Remove local `typeBadgeClass`, `statusBadgeClass`, `formatDate` functions and add `const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()` + +- [ ] **Commit:** +```bash +git add frontend/composables/useClientTicketHelpers.ts frontend/components/client-ticket/ClientTicketDetailModal.vue frontend/pages/portal/projects/\[id\]/index.vue frontend/components/admin/AdminClientTicketTab.vue +git commit -m "refactor(portal) : extract duplicated ticket helpers to useClientTicketHelpers composable" +``` + +### Task 22: Final commit — verify all files + +- [ ] **Run a final check** — Verify all new files are properly created and existing files are updated: +```bash +git status +``` + +Verify the following files exist: +- `frontend/middleware/auth.global.ts` (modified) +- `frontend/layouts/portal.vue` (new) +- `frontend/i18n/locales/fr.json` (modified) +- `frontend/services/dto/client-ticket.ts` (new) +- `frontend/services/client-tickets.ts` (new) +- `frontend/services/dto/task.ts` (modified) +- `frontend/services/dto/user-data.ts` (modified) +- `frontend/services/task-documents.ts` (modified) +- `frontend/pages/portal/index.vue` (new) +- `frontend/pages/portal/projects/[id]/index.vue` (new) +- `frontend/pages/portal/projects/[id]/new-ticket.vue` (new) +- `frontend/components/client-ticket/ClientTicketDetailModal.vue` (new) +- `frontend/components/task/TaskDocumentUpload.vue` (modified) +- `frontend/components/task/TaskCard.vue` (modified) +- `frontend/components/task/TaskModal.vue` (modified) +- `frontend/pages/my-tasks.vue` (modified) +- `frontend/pages/admin.vue` (modified) +- `frontend/components/admin/AdminClientTicketTab.vue` (new) +- `frontend/pages/login.vue` (modified) +- `frontend/composables/useClientTicketHelpers.ts` (new) diff --git a/docs/superpowers/plans/2026-03-15-client-portal-phase3.md b/docs/superpowers/plans/2026-03-15-client-portal-phase3.md new file mode 100644 index 0000000..b61a712 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-client-portal-phase3.md @@ -0,0 +1,970 @@ +# Client Portal Phase 3 — Notifications + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an in-app notification system so admins are alerted when a client submits a ticket, and clients are alerted when a ticket status changes. Includes a bell icon with dropdown in the navbar, a polling composable, and the full backend (entity, provider, controller, service). + +**Architecture:** `Notification` entity with API Platform CRUD (GetCollection auto-filtered by current user, Patch to mark as read) plus two custom Symfony endpoints (unread-count, mark-all-read). A `NotificationService` is called from the existing `ClientTicketNumberProcessor` (POST) and `ClientTicketStatusProcessor` (PATCH). Frontend uses a `useNotifications()` composable with 2-minute polling, rendered in a `NotificationBell.vue` component placed in `AppTopNav.vue`. + +> **Note:** Notification creation is handled via `NotificationService` injected into existing processors (`ClientTicketNumberProcessor` and `ClientTicketStatusProcessor`), rather than a separate `ClientTicketNotificationProcessor`. This is simpler and avoids processor decorator complexity. + +**Tech Stack:** PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16, Nuxt 4, Vue 3, TypeScript + +**Spec:** `docs/superpowers/specs/2026-03-15-client-portal-design.md` + +**Depends on:** Phase 1 + Phase 2 + +--- + +## Chunk 1: Notification Entity & Migration + +### Task 1: Create the Notification entity + +- [ ] **Create `src/Entity/Notification.php`** with the following content: + +```php + ['notification:read']], + denormalizationContext: ['groups' => ['notification:write']], + order: ['createdAt' => 'DESC'], +)] +#[ORM\Entity(repositoryClass: NotificationRepository::class)] +#[ORM\Index(columns: ['user_id'], name: 'idx_notification_user')] +#[ORM\Index(columns: ['user_id', 'is_read'], name: 'idx_notification_user_read')] +class Notification +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['notification:read'])] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[Groups(['notification:read'])] + private ?User $user = null; + + #[ORM\Column(length: 50)] + #[Groups(['notification:read'])] + private ?string $type = null; + + #[ORM\Column(length: 255)] + #[Groups(['notification:read'])] + private ?string $title = null; + + #[ORM\Column(type: Types::TEXT)] + #[Groups(['notification:read'])] + private ?string $message = null; + + #[ORM\ManyToOne(targetEntity: ClientTicket::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + #[Groups(['notification:read'])] + private ?ClientTicket $relatedTicket = null; + + #[ORM\Column] + #[Groups(['notification:read', 'notification:write'])] + private bool $isRead = false; + + #[ORM\Column] + #[Groups(['notification:read'])] + private ?DateTimeImmutable $createdAt = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(string $type): static + { + $this->type = $type; + + return $this; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + + public function getMessage(): ?string + { + return $this->message; + } + + public function setMessage(string $message): static + { + $this->message = $message; + + return $this; + } + + public function getRelatedTicket(): ?ClientTicket + { + return $this->relatedTicket; + } + + public function setRelatedTicket(?ClientTicket $relatedTicket): static + { + $this->relatedTicket = $relatedTicket; + + return $this; + } + + public function isRead(): bool + { + return $this->isRead; + } + + public function setIsRead(bool $isRead): static + { + $this->isRead = $isRead; + + return $this; + } + + public function getCreatedAt(): ?DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } +} +``` + +### Task 2: Create the NotificationRepository + +- [ ] **Create `src/Repository/NotificationRepository.php`**: + +```php + + */ +class NotificationRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Notification::class); + } + + public function countUnreadByUser(User $user): int + { + return (int) $this->createQueryBuilder('n') + ->select('COUNT(n.id)') + ->where('n.user = :user') + ->andWhere('n.isRead = false') + ->setParameter('user', $user) + ->getQuery() + ->getSingleScalarResult(); + } + + public function markAllReadByUser(User $user): int + { + return $this->createQueryBuilder('n') + ->update() + ->set('n.isRead', 'true') + ->where('n.user = :user') + ->andWhere('n.isRead = false') + ->setParameter('user', $user) + ->getQuery() + ->executeStatement(); + } +} +``` + +### Task 3: Generate and run the migration + +- [ ] **Run inside the PHP container** (`make shell`): + +```bash +php bin/console doctrine:migrations:diff +php bin/console doctrine:migrations:migrate --no-interaction +``` + +Verify that the `notification` table is created with columns `id`, `user_id`, `type`, `title`, `message`, `related_ticket_id`, `is_read`, `created_at`, and the two indexes `idx_notification_user` and `idx_notification_user_read`. + +- [ ] **Commit:** +```bash +git add src/Entity/Notification.php src/Repository/NotificationRepository.php migrations/ +git commit -m "feat(notification) : add Notification entity, repository, and migration" +``` + +--- + +## Chunk 2: NotificationProvider & Custom Endpoints + +### Task 4: Create the NotificationProvider + +- [ ] **Create `src/State/NotificationProvider.php`** — auto-filters by the current user: + +```php + + */ +final readonly class NotificationProvider implements ProviderInterface +{ + public function __construct( + private Security $security, + private NotificationRepository $notificationRepository, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|object + { + $user = $this->security->getUser(); + + return $this->notificationRepository->findBy( + ['user' => $user], + ['createdAt' => 'DESC'], + 30, + ); + } +} +``` + +- [ ] **Commit:** +```bash +git add src/State/NotificationProvider.php +git commit -m "feat(notification) : add NotificationProvider filtered by current user" +``` + +### Task 5: Create the UnreadCountController + +- [ ] **Create `src/Controller/NotificationUnreadCountController.php`**: + +```php +getUser(); + + $count = $this->notificationRepository->countUnreadByUser($user); + + return new JsonResponse(['count' => $count]); + } +} +``` + +### Task 6: Create the MarkAllReadController + +- [ ] **Create `src/Controller/MarkAllReadController.php`**: + +```php +getUser(); + + $this->notificationRepository->markAllReadByUser($user); + + return new Response(null, Response::HTTP_NO_CONTENT); + } +} +``` + +- [ ] **Commit:** +```bash +git add src/Controller/NotificationUnreadCountController.php src/Controller/MarkAllReadController.php +git commit -m "feat(notification) : add unread-count and mark-all-read custom controllers" +``` + +--- + +## Chunk 3: NotificationService & Processor Integration + +### Task 7: Create NotificationService + +- [ ] **Create `src/Service/NotificationService.php`** — responsible for creating notifications: + +```php +userRepository->findByRole('ROLE_ADMIN'); + $number = sprintf('CT-%03d', $ticket->getNumber()); + $projectName = $ticket->getProject()?->getName() ?? ''; + + foreach ($admins as $admin) { + $notification = new Notification(); + $notification->setUser($admin); + $notification->setType('ticket_created'); + $notification->setTitle('Nouveau ticket client ' . $number); + $notification->setMessage($ticket->getTitle() . ' — ' . $projectName); + $notification->setRelatedTicket($ticket); + $notification->setCreatedAt(new DateTimeImmutable()); + + $this->entityManager->persist($notification); + } + + $this->entityManager->flush(); + } + + /** + * Notify the ticket submitter that the status has changed. + */ + public function createForStatusChange(ClientTicket $ticket): void + { + $submittedBy = $ticket->getSubmittedBy(); + + if (null === $submittedBy) { + return; + } + + $number = sprintf('CT-%03d', $ticket->getNumber()); + $statusLabel = $ticket->getStatus(); + $message = 'Nouveau statut : ' . $statusLabel; + + if (null !== $ticket->getStatusComment() && '' !== $ticket->getStatusComment()) { + $message .= ' — ' . $ticket->getStatusComment(); + } + + $notification = new Notification(); + $notification->setUser($submittedBy); + $notification->setType('ticket_status_changed'); + $notification->setTitle('Ticket ' . $number . ' mis à jour'); + $notification->setMessage($message); + $notification->setRelatedTicket($ticket); + $notification->setCreatedAt(new DateTimeImmutable()); + + $this->entityManager->persist($notification); + $this->entityManager->flush(); + } +} +``` + +### Task 8: Add findByRole method to UserRepository + +- [ ] **Modify `src/Repository/UserRepository.php`** — Add the `findByRole` method at the end of the class, before the closing `}`: + +```php + /** + * @return User[] + */ + public function findByRole(string $role): array + { + return $this->createQueryBuilder('u') + ->where('u.roles LIKE :role') + ->setParameter('role', '%"' . $role . '"%') + ->getQuery() + ->getResult(); + } +``` + +- [ ] **Commit:** +```bash +git add src/Service/NotificationService.php src/Repository/UserRepository.php +git commit -m "feat(notification) : add NotificationService and UserRepository::findByRole" +``` + +### Task 9: Hook NotificationService into ClientTicketNumberProcessor (POST) + +- [ ] **Modify `src/State/ClientTicketNumberProcessor.php`** — Inject `NotificationService` in the constructor and call `createForTicketCreated()` after the ticket is persisted: + +Add to constructor parameters: +```php +private readonly NotificationService $notificationService, +``` + +Add import at the top: +```php +use App\Service\NotificationService; +``` + +After `$this->entityManager->flush();` in the POST handling block, add: +```php +$this->notificationService->createForTicketCreated($data); +``` + +### Task 10: Hook NotificationService into ClientTicketStatusProcessor (PATCH) + +- [ ] **Modify `src/State/ClientTicketStatusProcessor.php`** — Inject `NotificationService` in the constructor and call `createForStatusChange()` after the status update is persisted: + +Add to constructor parameters: +```php +private readonly NotificationService $notificationService, +``` + +Add import at the top: +```php +use App\Service\NotificationService; +``` + +After `$this->entityManager->flush();` in the PATCH handling block, add: +```php +$this->notificationService->createForStatusChange($data); +``` + +- [ ] **Commit:** +```bash +git add src/State/ClientTicketNumberProcessor.php src/State/ClientTicketStatusProcessor.php +git commit -m "feat(notification) : hook NotificationService into ticket processors" +``` + +--- + +## Chunk 4: Frontend — DTO & Service + +### Task 11: Create the Notification DTO + +- [ ] **Create `frontend/services/dto/notification.ts`**: + +```typescript +export type NotificationType = 'ticket_created' | 'ticket_status_changed' + +export type Notification = { + '@id'?: string + id: number + user: string + type: NotificationType + title: string + message: string + relatedTicket: string | null + isRead: boolean + createdAt: string +} +``` + +### Task 12: Create the notifications service + +- [ ] **Create `frontend/services/notifications.ts`**: + +```typescript +import type { Notification } from './dto/notification' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export function useNotificationService() { + const api = useApi() + + async function getAll(): Promise { + const data = await api.get>('/notifications') + return extractHydraMembers(data) + } + + async function markAsRead(id: number): Promise { + await api.patch(`/notifications/${id}`, { isRead: true }, { + toast: false, + }) + } + + async function markAllAsRead(): Promise { + await api.post('/notifications/mark-all-read', {}, { + toast: false, + }) + } + + async function getUnreadCount(): Promise { + const data = await api.get<{ count: number }>('/notifications/unread-count', {}, { + toast: false, + }) + return data.count + } + + return { getAll, markAsRead, markAllAsRead, getUnreadCount } +} +``` + +- [ ] **Commit:** +```bash +git add frontend/services/dto/notification.ts frontend/services/notifications.ts +git commit -m "feat(frontend) : add notification DTO and service" +``` + +--- + +## Chunk 5: Frontend — Composable & Component + +### Task 13: Create the useNotifications composable + +- [ ] **Create `frontend/composables/useNotifications.ts`**: + +```typescript +import type { Notification } from '~/services/dto/notification' +import { useNotificationService } from '~/services/notifications' + +const POLL_INTERVAL = 2 * 60 * 1000 // 2 minutes + +export function useNotifications() { + const unreadCount = useState('notification-unread-count', () => 0) + const notifications = useState('notification-list', () => []) + const isLoading = useState('notification-loading', () => false) + + const service = useNotificationService() + let pollTimer: ReturnType | null = null + + async function fetchUnreadCount(): Promise { + try { + unreadCount.value = await service.getUnreadCount() + } catch { + // Silently ignore polling errors + } + } + + async function fetchNotifications(): Promise { + isLoading.value = true + try { + notifications.value = await service.getAll() + } finally { + isLoading.value = false + } + } + + async function markAsRead(id: number): Promise { + await service.markAsRead(id) + const notif = notifications.value.find(n => n.id === id) + if (notif && !notif.isRead) { + notif.isRead = true + unreadCount.value = Math.max(0, unreadCount.value - 1) + } + } + + async function markAllAsRead(): Promise { + await service.markAllAsRead() + notifications.value.forEach(n => n.isRead = true) + unreadCount.value = 0 + } + + function startPolling(): void { + fetchUnreadCount() + pollTimer = setInterval(fetchUnreadCount, POLL_INTERVAL) + } + + function stopPolling(): void { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + } + + return { + unreadCount, + notifications, + isLoading, + fetchNotifications, + fetchUnreadCount, + markAsRead, + markAllAsRead, + startPolling, + stopPolling, + } +} +``` + +- [ ] **Commit:** +```bash +git add frontend/composables/useNotifications.ts +git commit -m "feat(frontend) : add useNotifications composable with polling" +``` + +### Task 14: Create the NotificationBell component + +- [ ] **Create `frontend/components/notification/NotificationBell.vue`**: + +```vue + + + + + +``` + +- [ ] **Commit:** +```bash +git add frontend/components/notification/NotificationBell.vue +git commit -m "feat(frontend) : add NotificationBell component with dropdown" +``` + +--- + +## Chunk 6: Layout Integration & i18n + +### Task 15: Integrate NotificationBell in AppTopNav + +- [ ] **Modify `frontend/components/ui/AppTopNav.vue`** — Add the notification bell to the left of the user avatar. Replace the existing `
` block (line 10): + +Replace: +```vue +
+
+``` + +With: +```vue +
+ +
+``` + +No imports needed — Nuxt auto-imports components from `frontend/components/`. + +- [ ] **Commit:** +```bash +git add frontend/components/ui/AppTopNav.vue +git commit -m "feat(frontend) : integrate NotificationBell in AppTopNav navbar" +``` + +### Task 16: Add i18n translations + +- [ ] **Modify `frontend/i18n/locales/fr.json`** — Add the following keys in the root object (insert alongside existing top-level keys): + +```json +"notification": { + "title": "Notifications", + "markAllRead": "Tout marquer comme lu", + "empty": "Aucune notification", + "ticketCreated": "Nouveau ticket client {number}", + "ticketStatusChanged": "Ticket {number} mis à jour", + "timeAgo": { + "now": "À l'instant", + "minutes": "Il y a {n} min", + "hours": "Il y a {n}h", + "days": "Il y a {n}j" + } +} +``` + +- [ ] **Commit:** +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(i18n) : add notification translations in French" +``` + +--- + +## Chunk 7: Verification & Cleanup + +### Task 17: Test backend endpoints manually + +- [ ] **Test the notification API endpoints** using the admin user (`admin`/`admin`): + +1. Log in at `POST /login_check` with `{"username":"admin","password":"admin"}` +2. `GET /api/notifications` — should return empty hydra collection (latest 30, no pagination) +3. `GET /api/notifications/unread-count` — should return `{"count": 0}` +4. Create a test client ticket as a ROLE_CLIENT user (from Phase 1/2) and verify a notification is created for the admin +5. `GET /api/notifications` — should now list the `ticket_created` notification +6. `GET /api/notifications/unread-count` — should return `{"count": 1}` +7. `PATCH /api/notifications/{id}` with `{"isRead": true}` — should mark notification as read +8. `POST /api/notifications/mark-all-read` — should return 204 + +### Task 18: Test frontend notification bell + +- [ ] **Start dev server** (`make dev-nuxt`) and verify: + +1. The bell icon appears in the top navigation bar, to the left of the user avatar +2. Badge shows unread count (or is hidden when 0) +3. Clicking the bell opens a dropdown with notification list +4. Clicking a notification marks it as read and navigates appropriately +5. "Tout marquer comme lu" button works +6. Polling updates the badge every 2 minutes + +- [ ] **Final commit (if any fixes needed):** +```bash +git add -A +git commit -m "fix(notification) : polish notification bell and fix edge cases" +``` diff --git a/docs/superpowers/plans/2026-03-15-date-filter.md b/docs/superpowers/plans/2026-03-15-date-filter.md new file mode 100644 index 0000000..73db2ac --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-date-filter.md @@ -0,0 +1,385 @@ +# Date Filter Component Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a reusable date filter component to the time-tracking page using `@vuepic/vue-datepicker`, enabling filtering by single day or date range. + +**Architecture:** A wrapper component `DateFilter.vue` encapsulates `VueDatePicker` with project-consistent styling. It integrates into the existing filter bar on the time-tracking page. Filtering is client-side, matching the existing project/tag filter pattern. + +**Tech Stack:** Vue 3, @vuepic/vue-datepicker, Tailwind CSS, @nuxtjs/i18n + +--- + +## Chunk 1: Setup and Component + +### Task 1: Install @vuepic/vue-datepicker and configure Nuxt + +**Files:** +- Modify: `frontend/package.json` +- Modify: `frontend/nuxt.config.ts:1-66` + +- [ ] **Step 1: Install the package** + +Run inside the PHP container (where Node is available): + +```bash +cd /home/r-dev/Lesstime/frontend && npm install @vuepic/vue-datepicker +``` + +- [ ] **Step 2: Add transpile config to nuxt.config.ts** + +In `frontend/nuxt.config.ts`, add `build.transpile` after the `typescript` block: + +```typescript +export default defineNuxtConfig({ + // ... existing config ... + typescript: { + strict: true + }, + build: { + transpile: ['@vuepic/vue-datepicker'] + } +}) +``` + +- [ ] **Step 3: Commit** + +```bash +git add frontend/package.json frontend/package-lock.json frontend/nuxt.config.ts +git commit -m "feat(frontend) : add @vuepic/vue-datepicker dependency" +``` + +--- + +### Task 2: Add i18n translations + +**Files:** +- Modify: `frontend/i18n/locales/fr.json:167-170` + +- [ ] **Step 1: Add date filter translations to fr.json** + +In `frontend/i18n/locales/fr.json`, add keys inside the existing `"common"` block: + +```json +"common": { + "cancel": "Annuler", + "loading": "Chargement...", + "dateFilter": "Date", + "today": "Aujourd'hui", + "thisWeek": "Cette semaine", + "clear": "Effacer" +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(frontend) : add date filter i18n translations" +``` + +--- + +### Task 3: Create DateFilter.vue component + +**Files:** +- Create: `frontend/components/ui/DateFilter.vue` + +- [ ] **Step 1: Create the component** + +Create `frontend/components/ui/DateFilter.vue`: + +```vue + + + + + +``` + +- [ ] **Step 2: Verify the component renders** + +Run `make dev-nuxt` and navigate to the time-tracking page (integration comes in Task 4). Check that no build errors occur. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/components/ui/DateFilter.vue +git commit -m "feat(frontend) : create DateFilter reusable component" +``` + +--- + +## Chunk 2: Integration + +### Task 4: Integrate DateFilter into time-tracking page + +**Files:** +- Modify: `frontend/pages/time-tracking.vue:15-73` (template filter bar) +- Modify: `frontend/pages/time-tracking.vue:138` (add ref) +- Modify: `frontend/pages/time-tracking.vue:184-193` (filteredEntries computed) + +- [ ] **Step 1: Add the date filter ref** + +In `frontend/pages/time-tracking.vue`, after line 138 (`selectedProjectId`), add: + +```typescript +const selectedDateFilter = ref(null) +``` + +- [ ] **Step 2: Add DateFilter to the template filter bar** + +In the filter bar `
` (line 15), after the tag MalioSelect block (after line 72), add: + +```vue + +``` + +- [ ] **Step 3: Add date filtering to filteredEntries computed** + +In `frontend/pages/time-tracking.vue`, update the `filteredEntries` computed (around line 184) to include date filtering: + +```typescript +const filteredEntries = computed(() => { + let result = entries.value + if (selectedProjectId.value) { + result = result.filter((e) => e.project?.id === selectedProjectId.value) + } + if (selectedTagId.value) { + result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value)) + } + if (selectedDateFilter.value) { + if (Array.isArray(selectedDateFilter.value)) { + const [start, end] = selectedDateFilter.value + const startDay = new Date(start) + startDay.setHours(0, 0, 0, 0) + const endDay = new Date(end) + endDay.setHours(23, 59, 59, 999) + result = result.filter((e) => { + const entryDate = new Date(e.startedAt) + return entryDate >= startDay && entryDate <= endDay + }) + } else { + const day = new Date(selectedDateFilter.value) + day.setHours(0, 0, 0, 0) + const nextDay = new Date(day) + nextDay.setDate(nextDay.getDate() + 1) + result = result.filter((e) => { + const entryDate = new Date(e.startedAt) + return entryDate >= day && entryDate < nextDay + }) + } + } + return result +}) +``` + +- [ ] **Step 4: Verify manually** + +Run `make dev-nuxt`, navigate to time-tracking page: +1. Verify DateFilter appears in the filter bar +2. Click a single day — entries filter to that day +3. Click a second day — entries filter to the range +4. Click "Aujourd'hui" — filters to today +5. Click "Cette semaine" — filters to current week +6. Clear the filter — all entries show again + +- [ ] **Step 5: Commit** + +```bash +git add frontend/pages/time-tracking.vue +git commit -m "feat(frontend) : integrate date filter into time-tracking page" +``` diff --git a/docs/superpowers/plans/2026-03-15-mcp-server.md b/docs/superpowers/plans/2026-03-15-mcp-server.md new file mode 100644 index 0000000..050be76 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-mcp-server.md @@ -0,0 +1,2176 @@ +# MCP Server Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an MCP server to Lesstime exposing projects, tasks, and time tracking for AI clients via STDIO and HTTP transports. + +**Architecture:** Install `symfony/mcp-bundle`, create 22 tool classes in `src/Mcp/Tool/` organized by domain (Project, Task, TaskMeta, TimeEntry, Reference). HTTP transport secured by API token on User entity with a custom Symfony authenticator. STDIO for local Claude Code usage. + +**Tech Stack:** symfony/mcp-bundle, Symfony 8 security (custom authenticator), Doctrine ORM, PHP 8.4 + +**Spec:** `docs/superpowers/specs/2026-03-15-mcp-server-design.md` + +--- + +## Chunk 1: Infrastructure (Bundle, Auth, Config) + +### Task 1: Install symfony/mcp-bundle + +**Files:** +- Modify: `composer.json` +- Create: `config/packages/mcp.yaml` +- Create: `config/routes/mcp.yaml` + +- [ ] **Step 1: Install the bundle via Composer** + +Run inside Docker container: +```bash +docker exec -u www-data php-lesstime-fpm composer require symfony/mcp-bundle +``` + +- [ ] **Step 2: Create MCP config** + +Create `config/packages/mcp.yaml`: +```yaml +mcp: + app: 'lesstime' + version: '1.0.0' + description: 'Lesstime project management — projects, tasks, time tracking' + instructions: | + This server provides access to the Lesstime project management system. + You can list/create/update/delete projects, tasks, and time entries. + Tasks belong to projects and have statuses, priorities, efforts, tags, and groups. + Statuses, priorities, efforts, and tags are GLOBAL (shared across all projects). + Groups are PER-PROJECT (each group belongs to one project). + Time entries track work duration and can be linked to projects and tasks. + Use list-statuses, list-priorities, list-efforts, list-tags, list-groups to discover + available metadata before creating or updating tasks. + Use list-users and list-clients to discover valid user and client IDs. + client_transports: + stdio: true + http: true + http: + path: /_mcp + session: + store: file + directory: '%kernel.cache_dir%/mcp-sessions' + ttl: 3600 +``` + +- [ ] **Step 3: Create MCP route config** + +Create `config/routes/mcp.yaml`: +```yaml +mcp: + resource: . + type: mcp +``` + +- [ ] **Step 4: Verify the bundle is loaded** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console mcp:server --help +``` +Expected: Help output for the `mcp:server` command (no errors). + +- [ ] **Step 5: Commit** + +```bash +git add composer.json composer.lock symfony.lock config/packages/mcp.yaml config/routes/mcp.yaml +git commit -m "feat : install symfony/mcp-bundle with STDIO + HTTP transport config" +``` + +--- + +### Task 2: Add API token to User entity + +**Files:** +- Modify: `src/Entity/User.php` +- Create: new Doctrine migration + +- [ ] **Step 1: Add apiToken property to User entity** + +In `src/Entity/User.php`, add after the `$createdAt` property: + +```php +#[ORM\Column(length: 64, unique: true, nullable: true)] +private ?string $apiToken = null; +``` + +Add getter and setter after the `eraseCredentials` method: + +```php +public function getApiToken(): ?string +{ + return $this->apiToken; +} + +public function setApiToken(?string $apiToken): static +{ + $this->apiToken = $apiToken; + + return $this; +} +``` + +- [ ] **Step 2: Generate and run the migration** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff +docker exec -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction +``` + +Expected: Migration runs successfully, adds `api_token` column to `user` table. + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/User.php migrations/ +git commit -m "feat : add apiToken field to User entity for MCP HTTP auth" +``` + +--- + +### Task 3: Create API token authenticator + +**Files:** +- Create: `src/Security/ApiTokenAuthenticator.php` +- Modify: `config/packages/security.yaml` + +- [ ] **Step 1: Create the authenticator class** + +Create `src/Security/ApiTokenAuthenticator.php`: + +```php +headers->has('Authorization') + && str_starts_with((string) $request->headers->get('Authorization'), 'Bearer '); + } + + public function authenticate(Request $request): Passport + { + $authHeader = (string) $request->headers->get('Authorization'); + $token = substr($authHeader, 7); // Remove "Bearer " prefix + + if ('' === $token) { + throw new CustomUserMessageAuthenticationException('API token missing.'); + } + + return new SelfValidatingPassport( + new UserBadge($token, function (string $token): ?\App\Entity\User { + $user = $this->userRepository->findOneBy(['apiToken' => $token]); + + if (null === $user) { + throw new CustomUserMessageAuthenticationException('Invalid API token.'); + } + + return $user; + }) + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; // Let the request continue + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return new JsonResponse( + ['error' => $exception->getMessageKey()], + Response::HTTP_UNAUTHORIZED + ); + } +} +``` + +- [ ] **Step 2: Add MCP firewall to security.yaml** + +In `config/packages/security.yaml`, add the `mcp` firewall **before** the `api` firewall: + +```yaml + mcp: + pattern: ^/_mcp + stateless: true + provider: app_user_provider + custom_authenticators: + - App\Security\ApiTokenAuthenticator +``` + +Add access control rule **before** the existing `/api` rules: + +```yaml + - { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY } +``` + +- [ ] **Step 3: Verify the firewall is registered** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console debug:firewall +``` + +Expected: `mcp` firewall appears in the list. + +- [ ] **Step 4: Commit** + +```bash +git add src/Security/ApiTokenAuthenticator.php config/packages/security.yaml +git commit -m "feat : add ApiTokenAuthenticator for MCP HTTP transport" +``` + +--- + +### Task 4: Create generate-api-token command + +**Files:** +- Create: `src/Command/GenerateApiTokenCommand.php` + +- [ ] **Step 1: Create the console command** + +Create `src/Command/GenerateApiTokenCommand.php`: + +```php +addArgument('username', InputArgument::REQUIRED, 'The username to generate a token for'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $username = $input->getArgument('username'); + + $user = $this->userRepository->findOneBy(['username' => $username]); + + if (null === $user) { + $io->error(\sprintf('User "%s" not found.', $username)); + + return Command::FAILURE; + } + + $token = bin2hex(random_bytes(32)); + $user->setApiToken($token); + $this->entityManager->flush(); + + $io->success(\sprintf('API token generated for user "%s":', $username)); + $io->writeln($token); + + return Command::SUCCESS; + } +} +``` + +- [ ] **Step 2: Verify the command works** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token admin +``` + +Expected: Outputs a 64-character hex token. + +- [ ] **Step 3: Commit** + +```bash +git add src/Command/GenerateApiTokenCommand.php +git commit -m "feat : add app:generate-api-token console command" +``` + +--- + +### Task 5: Add Nginx location and update fixtures + +**Files:** +- Modify: `docker/nginx/conf.d/lesstime.conf` +- Modify: `src/DataFixtures/AppFixtures.php` + +- [ ] **Step 1: Add Nginx location block for /_mcp** + +In `docker/nginx/conf.d/lesstime.conf`, add this block **before** the `location ^~ /api/` block: + +```nginx + location ^~ /_mcp { + root /var/www/html/public; + try_files $uri /index.php?$query_string; + } +``` + +- [ ] **Step 2: Add API token to admin fixture** + +In `src/DataFixtures/AppFixtures.php`, in the section where the admin user is created, add after `setPassword(...)`: + +```php +->setApiToken('dev-mcp-token-for-testing-only-do-not-use-in-production') +``` + +- [ ] **Step 3: Restart Nginx and reload fixtures** + +```bash +docker restart nginx-lesstime +docker exec -u www-data php-lesstime-fpm php bin/console doctrine:fixtures:load --no-interaction +``` + +- [ ] **Step 4: Test HTTP transport with curl** + +```bash +curl -s -X POST http://localhost:8082/_mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' +``` + +Expected: JSON-RPC response with server capabilities (or at least not a 401/404). + +- [ ] **Step 5: Test STDIO transport** + +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server +``` + +Expected: JSON-RPC response via stdout. + +- [ ] **Step 6: Commit** + +```bash +git add docker/nginx/conf.d/lesstime.conf src/DataFixtures/AppFixtures.php +git commit -m "feat : add Nginx /_mcp location and API token fixture for MCP" +``` + +--- + +## Chunk 2: Reference & Project Tools + +### Task 6: Reference tools (list-users, list-clients) + +**Files:** +- Create: `src/Mcp/Tool/Reference/ListUsersTool.php` +- Create: `src/Mcp/Tool/Reference/ListClientsTool.php` + +- [ ] **Step 1: Create ListUsersTool** + +Create `src/Mcp/Tool/Reference/ListUsersTool.php`: + +```php +userRepository->findBy([], ['username' => 'ASC']); + + return json_encode(array_map(fn($user) => [ + 'id' => $user->getId(), + 'username' => $user->getUsername(), + ], $users)); + } +} +``` + +- [ ] **Step 2: Create ListClientsTool** + +Create `src/Mcp/Tool/Reference/ListClientsTool.php`: + +```php +clientRepository->findBy([], ['name' => 'ASC']); + + return json_encode(array_map(fn($client) => [ + 'id' => $client->getId(), + 'name' => $client->getName(), + 'email' => $client->getEmail(), + ], $clients)); + } +} +``` + +- [ ] **Step 3: Verify tools are discovered** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console debug:container --tag=mcp.tool 2>/dev/null || docker exec -u www-data php-lesstime-fpm php bin/console mcp:server --help +``` + +Check that both tools appear in the registered MCP tools. + +- [ ] **Step 4: Commit** + +```bash +git add src/Mcp/Tool/Reference/ +git commit -m "feat : add list-users and list-clients MCP tools" +``` + +--- + +### Task 7: Project tools (list, get, create, update) + +**Files:** +- Create: `src/Mcp/Tool/Project/ListProjectsTool.php` +- Create: `src/Mcp/Tool/Project/GetProjectTool.php` +- Create: `src/Mcp/Tool/Project/CreateProjectTool.php` +- Create: `src/Mcp/Tool/Project/UpdateProjectTool.php` + +- [ ] **Step 1: Create ListProjectsTool** + +Create `src/Mcp/Tool/Project/ListProjectsTool.php`: + +```php +projectRepository->findBy(['archived' => $archived], ['name' => 'ASC']); + + return json_encode(array_map(fn($project) => [ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + ], $projects)); + } +} +``` + +- [ ] **Step 2: Create GetProjectTool** + +Create `src/Mcp/Tool/Project/GetProjectTool.php`: + +```php +projectRepository->find($id); + + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $id)); + } + + // Count tasks per status + $qb = $this->taskRepository->createQueryBuilder('t') + ->select('s.label AS statusLabel, COUNT(t.id) AS taskCount') + ->leftJoin('t.status', 's') + ->where('t.project = :project') + ->setParameter('project', $project) + ->groupBy('s.id, s.label'); + + $statusCounts = []; + $totalTasks = 0; + foreach ($qb->getQuery()->getResult() as $row) { + $label = $row['statusLabel'] ?? 'No status'; + $count = (int) $row['taskCount']; + $statusCounts[$label] = $count; + $totalTasks += $count; + } + + return json_encode([ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + 'taskSummary' => $statusCounts, + 'totalTasks' => $totalTasks, + ]); + } +} +``` + +- [ ] **Step 3: Create CreateProjectTool** + +Create `src/Mcp/Tool/Project/CreateProjectTool.php`: + +```php +setName($name); + $project->setCode($code); + + if (null !== $description) { + $project->setDescription($description); + } + if (null !== $color) { + $project->setColor($color); + } + if (null !== $clientId) { + $client = $this->clientRepository->find($clientId); + if (null === $client) { + throw new \InvalidArgumentException(\sprintf('Client with ID %d not found.', $clientId)); + } + $project->setClient($client); + } + + $this->entityManager->persist($project); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + ]); + } +} +``` + +- [ ] **Step 4: Create UpdateProjectTool** + +Create `src/Mcp/Tool/Project/UpdateProjectTool.php`: + +```php +projectRepository->find($id); + + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $id)); + } + + if (null !== $name) { + $project->setName($name); + } + if (null !== $code) { + $project->setCode($code); + } + if (null !== $description) { + $project->setDescription($description); + } + if (null !== $color) { + $project->setColor($color); + } + if (null !== $clientId) { + $client = $this->clientRepository->find($clientId); + if (null === $client) { + throw new \InvalidArgumentException(\sprintf('Client with ID %d not found.', $clientId)); + } + $project->setClient($client); + } + if (null !== $archived) { + $project->setArchived($archived); + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + 'description' => $project->getDescription(), + 'color' => $project->getColor(), + 'client' => $project->getClient() ? [ + 'id' => $project->getClient()->getId(), + 'name' => $project->getClient()->getName(), + ] : null, + 'archived' => $project->isArchived(), + ]); + } +} +``` + +- [ ] **Step 5: Test with STDIO** + +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server +``` + +Expected: JSON response listing all registered tools including `list-projects`, `get-project`, `create-project`, `update-project`. + +- [ ] **Step 6: Commit** + +```bash +git add src/Mcp/Tool/Project/ src/Mcp/Tool/Reference/ +git commit -m "feat : add project and reference MCP tools (list/get/create/update)" +``` + +--- + +## Chunk 3: Task Tools + +### Task 8: List and get task tools + +**Files:** +- Create: `src/Mcp/Tool/Task/ListTasksTool.php` +- Create: `src/Mcp/Tool/Task/GetTaskTool.php` + +- [ ] **Step 1: Create ListTasksTool** + +Create `src/Mcp/Tool/Task/ListTasksTool.php`: + +```php +taskRepository->createQueryBuilder('t') + ->leftJoin('t.status', 's')->addSelect('s') + ->leftJoin('t.priority', 'p')->addSelect('p') + ->leftJoin('t.assignee', 'a')->addSelect('a') + ->leftJoin('t.project', 'pr')->addSelect('pr') + ->leftJoin('t.effort', 'e')->addSelect('e') + ->leftJoin('t.group', 'g')->addSelect('g') + ->leftJoin('t.tags', 'tg')->addSelect('tg') + ->where('t.archived = :archived') + ->setParameter('archived', $archived) + ->orderBy('t.id', 'DESC') + ->setMaxResults($limit); + + if (null !== $projectId) { + $qb->andWhere('pr.id = :projectId')->setParameter('projectId', $projectId); + } + if (null !== $statusId) { + $qb->andWhere('s.id = :statusId')->setParameter('statusId', $statusId); + } + if (null !== $assigneeId) { + $qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId); + } + if (null !== $priorityId) { + $qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId); + } + if (null !== $groupId) { + $qb->andWhere('t.group = :groupId')->setParameter('groupId', $groupId); + } + + $tasks = $qb->getQuery()->getResult(); + + if (null !== $tagIds) { + $tasks = array_filter($tasks, function ($task) use ($tagIds) { + $taskTagIds = $task->getTags()->map(fn($t) => $t->getId())->toArray(); + + return !empty(array_intersect($tagIds, $taskTagIds)); + }); + } + + return json_encode(array_map(fn($task) => [ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'status' => $task->getStatus() ? [ + 'id' => $task->getStatus()->getId(), + 'label' => $task->getStatus()->getLabel(), + 'color' => $task->getStatus()->getColor(), + ] : null, + 'priority' => $task->getPriority() ? [ + 'id' => $task->getPriority()->getId(), + 'label' => $task->getPriority()->getLabel(), + 'color' => $task->getPriority()->getColor(), + ] : null, + 'assignee' => $task->getAssignee() ? [ + 'id' => $task->getAssignee()->getId(), + 'username' => $task->getAssignee()->getUsername(), + ] : null, + 'effort' => $task->getEffort() ? [ + 'id' => $task->getEffort()->getId(), + 'label' => $task->getEffort()->getLabel(), + ] : null, + 'group' => $task->getGroup() ? [ + 'id' => $task->getGroup()->getId(), + 'title' => $task->getGroup()->getTitle(), + ] : null, + 'project' => [ + 'id' => $task->getProject()->getId(), + 'code' => $task->getProject()->getCode(), + 'name' => $task->getProject()->getName(), + ], + 'tags' => $task->getTags()->map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), + 'archived' => $task->isArchived(), + ], array_values($tasks))); + } +} +``` + +- [ ] **Step 2: Create GetTaskTool** + +Create `src/Mcp/Tool/Task/GetTaskTool.php`: + +```php +taskRepository->find($id); + + if (null === $task) { + throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $id)); + } + + return json_encode([ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + 'description' => $task->getDescription(), + 'status' => $task->getStatus() ? [ + 'id' => $task->getStatus()->getId(), + 'label' => $task->getStatus()->getLabel(), + 'color' => $task->getStatus()->getColor(), + 'isFinal' => $task->getStatus()->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(), + ]); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Mcp/Tool/Task/ListTasksTool.php src/Mcp/Tool/Task/GetTaskTool.php +git commit -m "feat : add list-tasks and get-task MCP tools" +``` + +--- + +### Task 9: Create, update, delete task tools + +**Files:** +- Create: `src/Mcp/Tool/Task/CreateTaskTool.php` +- Create: `src/Mcp/Tool/Task/UpdateTaskTool.php` +- Create: `src/Mcp/Tool/Task/DeleteTaskTool.php` + +- [ ] **Step 1: Create CreateTaskTool** + +Create `src/Mcp/Tool/Task/CreateTaskTool.php`: + +```php +projectRepository->find($projectId); + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId)); + } + + $task = new Task(); + $task->setProject($project); + $task->setTitle($title); + $task->setNumber($this->taskRepository->findMaxNumberByProject($project) + 1); + + if (null !== $description) { + $task->setDescription($description); + } + if (null !== $statusId) { + $status = $this->taskStatusRepository->find($statusId); + if (null === $status) { + throw new \InvalidArgumentException(\sprintf('TaskStatus with ID %d not found.', $statusId)); + } + $task->setStatus($status); + } + if (null !== $priorityId) { + $priority = $this->taskPriorityRepository->find($priorityId); + if (null === $priority) { + throw new \InvalidArgumentException(\sprintf('TaskPriority with ID %d not found.', $priorityId)); + } + $task->setPriority($priority); + } + if (null !== $effortId) { + $effort = $this->taskEffortRepository->find($effortId); + if (null === $effort) { + throw new \InvalidArgumentException(\sprintf('TaskEffort with ID %d not found.', $effortId)); + } + $task->setEffort($effort); + } + if (null !== $assigneeId) { + $assignee = $this->userRepository->find($assigneeId); + if (null === $assignee) { + throw new \InvalidArgumentException(\sprintf('User with ID %d not found.', $assigneeId)); + } + $task->setAssignee($assignee); + } + if (null !== $groupId) { + $group = $this->taskGroupRepository->find($groupId); + if (null === $group) { + throw new \InvalidArgumentException(\sprintf('TaskGroup with ID %d not found.', $groupId)); + } + $task->setGroup($group); + } + if (null !== $tagIds) { + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId)); + } + $task->addTag($tag); + } + } + + $this->entityManager->persist($task); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + '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(), + ]); + } +} +``` + +- [ ] **Step 2: Create UpdateTaskTool** + +Create `src/Mcp/Tool/Task/UpdateTaskTool.php`: + +```php +taskRepository->find($id); + + if (null === $task) { + throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $id)); + } + + if (null !== $title) { + $task->setTitle($title); + } + if (null !== $description) { + $task->setDescription($description); + } + if (null !== $statusId) { + $status = $this->taskStatusRepository->find($statusId); + if (null === $status) { + throw new \InvalidArgumentException(\sprintf('TaskStatus with ID %d not found.', $statusId)); + } + $task->setStatus($status); + } + if (null !== $priorityId) { + $priority = $this->taskPriorityRepository->find($priorityId); + if (null === $priority) { + throw new \InvalidArgumentException(\sprintf('TaskPriority with ID %d not found.', $priorityId)); + } + $task->setPriority($priority); + } + if (null !== $effortId) { + $effort = $this->taskEffortRepository->find($effortId); + if (null === $effort) { + throw new \InvalidArgumentException(\sprintf('TaskEffort with ID %d not found.', $effortId)); + } + $task->setEffort($effort); + } + if (null !== $assigneeId) { + $assignee = $this->userRepository->find($assigneeId); + if (null === $assignee) { + throw new \InvalidArgumentException(\sprintf('User with ID %d not found.', $assigneeId)); + } + $task->setAssignee($assignee); + } + if (null !== $groupId) { + $group = $this->taskGroupRepository->find($groupId); + if (null === $group) { + throw new \InvalidArgumentException(\sprintf('TaskGroup with ID %d not found.', $groupId)); + } + $task->setGroup($group); + } + if (null !== $tagIds) { + // Clear existing tags and set new ones + foreach ($task->getTags()->toArray() as $existingTag) { + $task->removeTag($existingTag); + } + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId)); + } + $task->addTag($tag); + } + } + if (null !== $archived) { + $task->setArchived($archived); + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $task->getId(), + 'number' => $task->getNumber(), + 'title' => $task->getTitle(), + '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(), + ]); + } +} +``` + +- [ ] **Step 3: Create DeleteTaskTool** + +Create `src/Mcp/Tool/Task/DeleteTaskTool.php`: + +```php +taskRepository->find($id); + + if (null === $task) { + throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $id)); + } + + $taskCode = $task->getProject()->getCode() . '-' . $task->getNumber(); + $this->entityManager->remove($task); + $this->entityManager->flush(); + + return json_encode([ + 'success' => true, + 'message' => \sprintf('Task %s deleted.', $taskCode), + ]); + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/Mcp/Tool/Task/ +git commit -m "feat : add create-task, update-task, delete-task MCP tools" +``` + +--- + +## Chunk 4: TaskMeta & TimeEntry Tools + +### Task 10: TaskMeta tools (statuses, priorities, efforts, tags, groups) + +**Files:** +- Create: `src/Mcp/Tool/TaskMeta/ListStatusesTool.php` +- Create: `src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php` +- Create: `src/Mcp/Tool/TaskMeta/ListEffortsTool.php` +- Create: `src/Mcp/Tool/TaskMeta/ListTagsTool.php` +- Create: `src/Mcp/Tool/TaskMeta/ListGroupsTool.php` +- Create: `src/Mcp/Tool/TaskMeta/CreateGroupTool.php` +- Create: `src/Mcp/Tool/TaskMeta/UpdateGroupTool.php` + +- [ ] **Step 1: Create ListStatusesTool** + +Create `src/Mcp/Tool/TaskMeta/ListStatusesTool.php`: + +```php +taskStatusRepository->findBy([], ['position' => 'ASC']); + + return json_encode(array_map(fn($s) => [ + 'id' => $s->getId(), + 'label' => $s->getLabel(), + 'color' => $s->getColor(), + 'position' => $s->getPosition(), + 'isFinal' => $s->getIsFinal(), + ], $statuses)); + } +} +``` + +- [ ] **Step 2: Create ListPrioritiesTool** + +Create `src/Mcp/Tool/TaskMeta/ListPrioritiesTool.php`: + +```php +taskPriorityRepository->findBy([], ['label' => 'ASC']); + + return json_encode(array_map(fn($p) => [ + 'id' => $p->getId(), + 'label' => $p->getLabel(), + 'color' => $p->getColor(), + ], $priorities)); + } +} +``` + +- [ ] **Step 3: Create ListEffortsTool** + +Create `src/Mcp/Tool/TaskMeta/ListEffortsTool.php`: + +```php +taskEffortRepository->findBy([], ['label' => 'ASC']); + + return json_encode(array_map(fn($e) => [ + 'id' => $e->getId(), + 'label' => $e->getLabel(), + ], $efforts)); + } +} +``` + +- [ ] **Step 4: Create ListTagsTool** + +Create `src/Mcp/Tool/TaskMeta/ListTagsTool.php`: + +```php +taskTagRepository->findBy([], ['label' => 'ASC']); + + return json_encode(array_map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + 'color' => $t->getColor(), + ], $tags)); + } +} +``` + +- [ ] **Step 5: Create ListGroupsTool** + +Create `src/Mcp/Tool/TaskMeta/ListGroupsTool.php`: + +```php + $archived]; + if (null !== $projectId) { + $criteria['project'] = $projectId; + } + + $groups = $this->taskGroupRepository->findBy($criteria, ['title' => 'ASC']); + + return json_encode(array_map(fn($g) => [ + 'id' => $g->getId(), + 'title' => $g->getTitle(), + 'description' => $g->getDescription(), + 'color' => $g->getColor(), + 'project' => [ + 'id' => $g->getProject()->getId(), + 'code' => $g->getProject()->getCode(), + 'name' => $g->getProject()->getName(), + ], + 'archived' => $g->isArchived(), + ], $groups)); + } +} +``` + +- [ ] **Step 6: Create CreateGroupTool** + +Create `src/Mcp/Tool/TaskMeta/CreateGroupTool.php`: + +```php +projectRepository->find($projectId); + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId)); + } + + $group = new TaskGroup(); + $group->setProject($project); + $group->setTitle($title); + + if (null !== $description) { + $group->setDescription($description); + } + if (null !== $color) { + $group->setColor($color); + } + + $this->entityManager->persist($group); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $group->getId(), + 'title' => $group->getTitle(), + 'description' => $group->getDescription(), + 'color' => $group->getColor(), + 'project' => [ + 'id' => $project->getId(), + 'code' => $project->getCode(), + 'name' => $project->getName(), + ], + 'archived' => $group->isArchived(), + ]); + } +} +``` + +- [ ] **Step 7: Create UpdateGroupTool** + +Create `src/Mcp/Tool/TaskMeta/UpdateGroupTool.php`: + +```php +taskGroupRepository->find($id); + + if (null === $group) { + throw new \InvalidArgumentException(\sprintf('TaskGroup with ID %d not found.', $id)); + } + + if (null !== $title) { + $group->setTitle($title); + } + if (null !== $description) { + $group->setDescription($description); + } + if (null !== $color) { + $group->setColor($color); + } + if (null !== $archived) { + $group->setArchived($archived); + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $group->getId(), + 'title' => $group->getTitle(), + 'description' => $group->getDescription(), + 'color' => $group->getColor(), + 'project' => [ + 'id' => $group->getProject()->getId(), + 'code' => $group->getProject()->getCode(), + 'name' => $group->getProject()->getName(), + ], + 'archived' => $group->isArchived(), + ]); + } +} +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/Mcp/Tool/TaskMeta/ +git commit -m "feat : add task metadata MCP tools (statuses, priorities, efforts, tags, groups CRUD)" +``` + +--- + +### Task 11: TimeEntry tools + +**Files:** +- Create: `src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php` +- Create: `src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php` +- Create: `src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php` +- Create: `src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php` + +- [ ] **Step 1: Create ListTimeEntriesTool** + +Create `src/Mcp/Tool/TimeEntry/ListTimeEntriesTool.php`: + +```php +timeEntryRepository->createQueryBuilder('te') + ->leftJoin('te.user', 'u')->addSelect('u') + ->leftJoin('te.project', 'p')->addSelect('p') + ->leftJoin('te.task', 't')->addSelect('t') + ->leftJoin('te.tags', 'tg')->addSelect('tg') + ->orderBy('te.startedAt', 'DESC') + ->setMaxResults($limit); + + if (null !== $userId) { + $qb->andWhere('u.id = :userId')->setParameter('userId', $userId); + } + if (null !== $projectId) { + $qb->andWhere('p.id = :projectId')->setParameter('projectId', $projectId); + } + if (null !== $taskId) { + $qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId); + } + if (null !== $startDate) { + $qb->andWhere('te.startedAt >= :startDate') + ->setParameter('startDate', new \DateTimeImmutable($startDate . ' 00:00:00')); + } + if (null !== $endDate) { + $qb->andWhere('te.startedAt <= :endDate') + ->setParameter('endDate', new \DateTimeImmutable($endDate . ' 23:59:59')); + } + + $entries = $qb->getQuery()->getResult(); + + return json_encode(array_map(fn($entry) => [ + 'id' => $entry->getId(), + 'title' => $entry->getTitle(), + 'description' => $entry->getDescription(), + 'startedAt' => $entry->getStartedAt()?->format('c'), + 'stoppedAt' => $entry->getStoppedAt()?->format('c'), + 'duration' => $entry->getStoppedAt() && $entry->getStartedAt() + ? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60) + : null, + 'user' => [ + 'id' => $entry->getUser()->getId(), + 'username' => $entry->getUser()->getUsername(), + ], + 'project' => $entry->getProject() ? [ + 'id' => $entry->getProject()->getId(), + 'code' => $entry->getProject()->getCode(), + 'name' => $entry->getProject()->getName(), + ] : null, + 'task' => $entry->getTask() ? [ + 'id' => $entry->getTask()->getId(), + 'number' => $entry->getTask()->getNumber(), + 'title' => $entry->getTask()->getTitle(), + ] : null, + 'tags' => $entry->getTags()->map(fn($t) => [ + 'id' => $t->getId(), + 'label' => $t->getLabel(), + ])->toArray(), + ], $entries)); + } +} +``` + +- [ ] **Step 2: Create CreateTimeEntryTool** + +Create `src/Mcp/Tool/TimeEntry/CreateTimeEntryTool.php`: + +```php +userRepository->find($userId); + if (null === $user) { + throw new \InvalidArgumentException(\sprintf('User with ID %d not found.', $userId)); + } + + // Check for existing active timer if creating a new active one + if (null === $stoppedAt) { + $activeEntry = $this->timeEntryRepository->findActiveByUser($user); + if (null !== $activeEntry) { + throw new \InvalidArgumentException(\sprintf('User "%s" already has an active timer (ID %d). Stop it before starting a new one.', $user->getUsername(), $activeEntry->getId())); + } + } + + $entry = new TimeEntry(); + $entry->setUser($user); + $entry->setStartedAt(new \DateTimeImmutable($startedAt)); + + if (null !== $title) { + $entry->setTitle($title); + } + if (null !== $stoppedAt) { + $entry->setStoppedAt(new \DateTimeImmutable($stoppedAt)); + } + if (null !== $description) { + $entry->setDescription($description); + } + if (null !== $projectId) { + $project = $this->projectRepository->find($projectId); + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId)); + } + $entry->setProject($project); + } + if (null !== $taskId) { + $task = $this->taskRepository->find($taskId); + if (null === $task) { + throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $taskId)); + } + $entry->setTask($task); + } + if (null !== $tagIds) { + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId)); + } + $entry->addTag($tag); + } + } + + $this->entityManager->persist($entry); + $this->entityManager->flush(); + + return json_encode([ + 'id' => $entry->getId(), + 'title' => $entry->getTitle(), + '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(), + ]); + } +} +``` + +- [ ] **Step 3: Create UpdateTimeEntryTool** + +Create `src/Mcp/Tool/TimeEntry/UpdateTimeEntryTool.php`: + +```php +timeEntryRepository->find($id); + + if (null === $entry) { + throw new \InvalidArgumentException(\sprintf('TimeEntry with ID %d not found.', $id)); + } + + if (null !== $title) { + $entry->setTitle($title); + } + if (null !== $startedAt) { + $entry->setStartedAt(new \DateTimeImmutable($startedAt)); + } + if (null !== $stoppedAt) { + $entry->setStoppedAt(new \DateTimeImmutable($stoppedAt)); + } + if (null !== $description) { + $entry->setDescription($description); + } + if (null !== $projectId) { + $project = $this->projectRepository->find($projectId); + if (null === $project) { + throw new \InvalidArgumentException(\sprintf('Project with ID %d not found.', $projectId)); + } + $entry->setProject($project); + } + if (null !== $taskId) { + $task = $this->taskRepository->find($taskId); + if (null === $task) { + throw new \InvalidArgumentException(\sprintf('Task with ID %d not found.', $taskId)); + } + $entry->setTask($task); + } + if (null !== $tagIds) { + foreach ($entry->getTags()->toArray() as $existingTag) { + $entry->removeTag($existingTag); + } + foreach ($tagIds as $tagId) { + $tag = $this->taskTagRepository->find($tagId); + if (null === $tag) { + throw new \InvalidArgumentException(\sprintf('TaskTag with ID %d not found.', $tagId)); + } + $entry->addTag($tag); + } + } + + $this->entityManager->flush(); + + return json_encode([ + 'id' => $entry->getId(), + 'title' => $entry->getTitle(), + 'startedAt' => $entry->getStartedAt()?->format('c'), + 'stoppedAt' => $entry->getStoppedAt()?->format('c'), + 'duration' => $entry->getStoppedAt() && $entry->getStartedAt() + ? (int) round(($entry->getStoppedAt()->getTimestamp() - $entry->getStartedAt()->getTimestamp()) / 60) + : null, + 'user' => ['id' => $entry->getUser()->getId(), 'username' => $entry->getUser()->getUsername()], + 'project' => $entry->getProject() ? [ + 'id' => $entry->getProject()->getId(), + 'code' => $entry->getProject()->getCode(), + '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(), + ]); + } +} +``` + +- [ ] **Step 4: Create DeleteTimeEntryTool** + +Create `src/Mcp/Tool/TimeEntry/DeleteTimeEntryTool.php`: + +```php +timeEntryRepository->find($id); + + if (null === $entry) { + throw new \InvalidArgumentException(\sprintf('TimeEntry with ID %d not found.', $id)); + } + + $this->entityManager->remove($entry); + $this->entityManager->flush(); + + return json_encode([ + 'success' => true, + 'message' => 'Time entry deleted.', + ]); + } +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/Mcp/Tool/TimeEntry/ +git commit -m "feat : add time entry MCP tools (list, create, update, delete)" +``` + +--- + +## Chunk 5: Integration Testing & Claude Code Setup + +### Task 12: End-to-end verification + +- [ ] **Step 1: Clear cache and verify all tools are registered** + +```bash +docker exec -u www-data php-lesstime-fpm php bin/console cache:clear +``` + +Then list all tools via STDIO: +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server +``` + +Expected: JSON response listing all 21 tools: `list-users`, `list-clients`, `list-projects`, `get-project`, `create-project`, `update-project`, `list-tasks`, `get-task`, `create-task`, `update-task`, `delete-task`, `list-statuses`, `list-priorities`, `list-efforts`, `list-tags`, `list-groups`, `create-group`, `update-group`, `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry` (note: delete-time-entry = 22nd tool, but spec counts 21 — recheck). + +- [ ] **Step 2: Test a tool call via STDIO** + +```bash +echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list-projects","arguments":{}}}' | docker exec -i php-lesstime-fpm php bin/console mcp:server +``` + +Expected: JSON response with the list of fixture projects (SIRH, CRM, ERP, Site vitrine). + +- [ ] **Step 3: Test HTTP transport with auth** + +```bash +docker restart nginx-lesstime +``` + +Initialize MCP session: +```bash +curl -s -X POST http://localhost:8082/_mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer dev-mcp-token-for-testing-only-do-not-use-in-production" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' +``` + +Expected: JSON-RPC response with server info and capabilities. + +- [ ] **Step 4: Test HTTP auth rejection** + +```bash +curl -s -X POST http://localhost:8082/_mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer wrong-token" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' +``` + +Expected: 401 Unauthorized response. + +- [ ] **Step 5: Configure Claude Code (STDIO)** + +Add to `.claude/settings.json` (or project-level `.claude/settings.local.json`): + +```json +{ + "mcpServers": { + "lesstime": { + "command": "docker", + "args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"], + "cwd": "/home/r-dev/Lesstime" + } + } +} +``` + +- [ ] **Step 6: Run PHP CS Fixer on all new files** + +```bash +docker exec -u www-data php-lesstime-fpm vendor/bin/php-cs-fixer fix src/Mcp/ --rules=@Symfony,@PSR12 --allow-risky=yes +docker exec -u www-data php-lesstime-fpm vendor/bin/php-cs-fixer fix src/Security/ApiTokenAuthenticator.php --rules=@Symfony,@PSR12 --allow-risky=yes +docker exec -u www-data php-lesstime-fpm vendor/bin/php-cs-fixer fix src/Command/GenerateApiTokenCommand.php --rules=@Symfony,@PSR12 --allow-risky=yes +``` + +- [ ] **Step 7: Final commit if CS Fixer changed anything** + +```bash +git add -A src/Mcp/ src/Security/ src/Command/ +git diff --cached --quiet || git commit -m "style : apply PHP CS Fixer to MCP server code" +``` diff --git a/docs/superpowers/plans/2026-03-15-task-documents.md b/docs/superpowers/plans/2026-03-15-task-documents.md new file mode 100644 index 0000000..5d00581 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-task-documents.md @@ -0,0 +1,1302 @@ +# Task Documents Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow users to attach documents to tasks with drag & drop / file selection, preview images and PDFs in a fullscreen modal, and download any file. + +**Architecture:** New `TaskDocument` entity with API Platform CRUD (multipart POST via custom Processor, download via custom Provider). Doctrine `EntityListener` for file cleanup on delete/cascade. Frontend: 3 components (upload zone, document list, preview modal) integrated into `TaskModal.vue`. + +**Tech Stack:** PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, Nuxt 4, Vue 3, TypeScript, Tailwind CSS + +**Spec:** `docs/superpowers/specs/2026-03-15-task-documents-design.md` + +--- + +## Chunk 1: Backend — Entity, Migration, Config + +### Task 1: PHP/Nginx upload limits + +**Files:** +- Modify: `docker/php/config/php.ini` +- Modify: `docker/nginx/conf.d/lesstime.conf` + +- [ ] **Step 1: Add upload limits to php.ini** + +Append to `docker/php/config/php.ini`: + +```ini +[Upload] +upload_max_filesize = 50M +post_max_size = 55M +``` + +- [ ] **Step 2: Add client_max_body_size to Nginx** + +Add `client_max_body_size 55m;` inside the `server` block of `docker/nginx/conf.d/lesstime.conf`, after `index index.html;`: + +```nginx +client_max_body_size 55m; +``` + +- [ ] **Step 3: Restart containers to apply config** + +```bash +docker restart php-lesstime-fpm nginx-lesstime +``` + +- [ ] **Step 4: Commit** + +```bash +git add docker/php/config/php.ini docker/nginx/conf.d/lesstime.conf +git commit -m "feat(config) : set upload limits to 50MB for task documents" +``` + +--- + +### Task 1b: Docker volume for uploads persistence + +**Files:** +- Modify: `docker-compose.yml` + +- [ ] **Step 1: Add named volume for uploads** + +In `docker-compose.yml`, add a named volume `uploads_data` and mount it in the `php` service: + +Under `php.volumes`, add: + +```yaml + - uploads_data:/var/www/html/var/uploads +``` + +Under top-level `volumes`, add: + +```yaml + uploads_data: +``` + +- [ ] **Step 2: Restart containers** + +```bash +docker compose down && docker compose up -d +``` + +- [ ] **Step 3: Commit** + +```bash +git add docker-compose.yml +git commit -m "feat(docker) : add named volume for document uploads persistence" +``` + +--- + +### Task 2: TaskDocument entity + +**Files:** +- Create: `src/Entity/TaskDocument.php` + +- [ ] **Step 1: Create the TaskDocument entity** + +```php + ['task_document:read']], + denormalizationContext: ['groups' => ['task_document:write']], + order: ['id' => 'DESC'], +)] +#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact'])] +#[ORM\Entity] +#[ORM\EntityListeners([\App\EventListener\TaskDocumentListener::class])] +class TaskDocument +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['task_document:read', 'task:read'])] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[Groups(['task_document:read', 'task_document:write'])] + private ?Task $task = null; + + #[ORM\Column(length: 255)] + #[Groups(['task_document:read', 'task:read'])] + private ?string $originalName = null; + + #[ORM\Column(length: 255)] + #[Groups(['task_document:read', 'task:read'])] + private ?string $fileName = null; + + #[ORM\Column(length: 100)] + #[Groups(['task_document:read', 'task:read'])] + private ?string $mimeType = null; + + #[ORM\Column] + #[Groups(['task_document:read', 'task:read'])] + private ?int $size = null; + + #[ORM\Column(type: 'datetime_immutable')] + #[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'])] + private ?User $uploadedBy = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getTask(): ?Task + { + return $this->task; + } + + public function setTask(?Task $task): static + { + $this->task = $task; + + return $this; + } + + public function getOriginalName(): ?string + { + return $this->originalName; + } + + public function setOriginalName(string $originalName): static + { + $this->originalName = $originalName; + + return $this; + } + + public function getFileName(): ?string + { + return $this->fileName; + } + + public function setFileName(string $fileName): static + { + $this->fileName = $fileName; + + return $this; + } + + public function getMimeType(): ?string + { + return $this->mimeType; + } + + public function setMimeType(string $mimeType): static + { + $this->mimeType = $mimeType; + + return $this; + } + + public function getSize(): ?int + { + return $this->size; + } + + public function setSize(int $size): static + { + $this->size = $size; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getUploadedBy(): ?User + { + return $this->uploadedBy; + } + + public function setUploadedBy(?User $uploadedBy): static + { + $this->uploadedBy = $uploadedBy; + + return $this; + } +} +``` + +- [ ] **Step 2: Add `documents` relation to Task entity** + +In `src/Entity/Task.php`, add the `documents` OneToMany collection: + +1. Add import: `use Doctrine\Common\Collections\Collection;` (already present) +2. Add property after `$archived`: + +```php +/** @var Collection */ +#[ORM\OneToMany(targetEntity: TaskDocument::class, mappedBy: 'task', cascade: ['remove'])] +#[Groups(['task:read'])] +private Collection $documents; +``` + +3. In constructor, add: `$this->documents = new ArrayCollection();` + +4. Add getter: + +```php +/** @return Collection */ +public function getDocuments(): Collection +{ + return $this->documents; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/TaskDocument.php src/Entity/Task.php +git commit -m "feat : add TaskDocument entity with Task relation" +``` + +--- + +### Task 3: Generate and run migration + +**Files:** +- Create: `migrations/VersionXXX.php` (auto-generated) + +- [ ] **Step 1: Generate migration** + +```bash +docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:diff +``` + +- [ ] **Step 2: Review the generated migration** + +Read the file and verify it creates `task_document` table with correct columns, indexes, and foreign keys. + +- [ ] **Step 3: Run migration** + +```bash +docker exec -t -u www-data php-lesstime-fpm php bin/console doctrine:migrations:migrate --no-interaction +``` + +- [ ] **Step 4: Commit** + +```bash +git add migrations/ +git commit -m "feat : add task_document migration" +``` + +--- + +### Task 4: TaskDocumentListener (file cleanup on delete) + +**Files:** +- Create: `src/EventListener/TaskDocumentListener.php` + +- [ ] **Step 1: Create the listener** + +```php +uploadDir . '/' . $document->getFileName(); + + if (file_exists($filePath)) { + if (!unlink($filePath)) { + $this->logger->warning('Failed to delete document file: {path}', ['path' => $filePath]); + } + } else { + $this->logger->warning('Document file not found on disk: {path}', ['path' => $filePath]); + } + } +} +``` + +- [ ] **Step 2: Register the service with uploadDir parameter** + +Create `config/services_task_document.yaml` or add to `config/services.yaml`: + +```yaml +# In config/services.yaml, add: +parameters: + task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents' + +services: + App\EventListener\TaskDocumentListener: + arguments: + $uploadDir: '%task_document_upload_dir%' +``` + +If `config/services.yaml` already has `parameters:` section, merge into it. If not, add the parameter block. + +- [ ] **Step 3: Create upload directory** + +```bash +mkdir -p var/uploads/documents +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/EventListener/TaskDocumentListener.php config/services.yaml +git commit -m "feat : add TaskDocumentListener for file cleanup on delete" +``` + +--- + +### Task 5: TaskDocumentProcessor (upload handler) + +**Files:** +- Create: `src/State/TaskDocumentProcessor.php` + +- [ ] **Step 1: Create the processor** + +```php + + */ +final readonly class TaskDocumentProcessor implements ProcessorInterface +{ + private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB + + public function __construct( + private EntityManagerInterface $entityManager, + private Security $security, + private RequestStack $requestStack, + private string $uploadDir, + ) {} + + /** + * @param TaskDocument $data + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument + { + $request = $this->requestStack->getCurrentRequest(); + + if (null === $request) { + throw new BadRequestHttpException('No request available.'); + } + + $file = $request->files->get('file'); + + if (null === $file || !$file->isValid()) { + throw new BadRequestHttpException('No valid file uploaded.'); + } + + if ($file->getSize() > self::MAX_FILE_SIZE) { + throw new BadRequestHttpException('File size exceeds 50 MB limit.'); + } + + $taskIri = $request->request->get('task'); + + if (null === $taskIri || '' === $taskIri) { + throw new BadRequestHttpException('Task IRI is required.'); + } + + // Extract task ID from IRI (e.g., "/api/tasks/42" -> 42) + $taskId = (int) basename((string) $taskIri); + $task = $this->entityManager->getRepository(Task::class)->find($taskId); + + if (null === $task) { + throw new BadRequestHttpException('Task not found.'); + } + + // Capture file metadata BEFORE move() — move invalidates the temp file + $originalName = $file->getClientOriginalName(); + $extension = $file->getClientOriginalExtension() ?: 'bin'; + $mimeType = $file->getClientMimeType() ?? 'application/octet-stream'; + $fileSize = $file->getSize(); + $uuid = Uuid::v4()->toRfc4122(); + $fileName = $uuid . '.' . $extension; + + if (!is_dir($this->uploadDir)) { + mkdir($this->uploadDir, 0o775, true); + } + + $file->move($this->uploadDir, $fileName); + + $document = new TaskDocument(); + $document->setTask($task); + $document->setOriginalName($originalName); + $document->setFileName($fileName); + $document->setMimeType($mimeType); + $document->setSize($fileSize); + $document->setCreatedAt(new \DateTimeImmutable()); + $document->setUploadedBy($this->security->getUser()); + + $this->entityManager->persist($document); + $this->entityManager->flush(); + + return $document; + } +} +``` + +- [ ] **Step 2: Register uploadDir injection** + +In `config/services.yaml`, add: + +```yaml +App\State\TaskDocumentProcessor: + arguments: + $uploadDir: '%task_document_upload_dir%' +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/State/TaskDocumentProcessor.php config/services.yaml +git commit -m "feat : add TaskDocumentProcessor for multipart file upload" +``` + +--- + +### Task 6: TaskDocumentDownloadController (file download) + +**Files:** +- Create: `src/Controller/TaskDocumentDownloadController.php` +- Modify: `config/routes.yaml` (or create `config/routes/task_document.yaml`) + +**Why a controller instead of a Provider:** API Platform providers return resource objects that get serialized. A file download needs to return a `BinaryFileResponse` directly, which bypasses API Platform's serialization pipeline. A Symfony controller is the correct approach for binary file serving. + +- [ ] **Step 1: Create the download controller** + +```php +entityManager->getRepository(TaskDocument::class)->find($id); + + if (null === $document) { + throw new NotFoundHttpException('Document not found.'); + } + + $filePath = $this->uploadDir . '/' . $document->getFileName(); + + if (!file_exists($filePath)) { + throw new NotFoundHttpException('File not found on disk.'); + } + + $response = new BinaryFileResponse($filePath); + $mimeType = $document->getMimeType() ?? 'application/octet-stream'; + + // Inline for images and PDFs, attachment for everything else + $disposition = str_starts_with($mimeType, 'image/') || $mimeType === 'application/pdf' + ? ResponseHeaderBag::DISPOSITION_INLINE + : ResponseHeaderBag::DISPOSITION_ATTACHMENT; + + $response->setContentDisposition($disposition, $document->getOriginalName()); + $response->headers->set('Content-Type', $mimeType); + + return $response; + } +} +``` + +- [ ] **Step 2: Register uploadDir injection** + +In `config/services.yaml`, add: + +```yaml +App\Controller\TaskDocumentDownloadController: + arguments: + $uploadDir: '%task_document_upload_dir%' +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Controller/TaskDocumentDownloadController.php config/services.yaml +git commit -m "feat : add TaskDocumentDownloadController for file download" +``` + +--- + +### Task 7: Verify backend API + +- [ ] **Step 1: Clear cache and verify no errors** + +```bash +docker exec -t -u www-data php-lesstime-fpm php bin/console cache:clear +``` + +- [ ] **Step 2: Test upload via curl** + +```bash +curl -X POST http://localhost:8082/api/task_documents \ + -H "Cookie: BEARER=" \ + -F "file=@/path/to/test-file.png" \ + -F "task=/api/tasks/1" +``` + +Verify response returns JSON with document metadata. + +- [ ] **Step 3: Test download** + +```bash +curl -I http://localhost:8082/api/task_documents/1/download \ + -H "Cookie: BEARER=" +``` + +Verify `Content-Type` and `Content-Disposition` headers. + +- [ ] **Step 4: Test delete** + +```bash +curl -X DELETE http://localhost:8082/api/task_documents/1 \ + -H "Cookie: BEARER=" +``` + +Verify document removed from DB and file deleted from disk. + +--- + +## Chunk 2: Frontend — Service, DTO, Components + +### Task 8: TypeScript DTO and service + +**Files:** +- Create: `frontend/services/dto/task-document.ts` +- Create: `frontend/services/task-documents.ts` + +- [ ] **Step 1: Create DTO** + +```typescript +import type { UserData } from './user-data' + +export type TaskDocument = { + '@id'?: string + id: number + task: string + originalName: string + fileName: string + mimeType: string + size: number + createdAt: string + uploadedBy: UserData | null +} +``` + +- [ ] **Step 2: Add `documents` to Task DTO** + +In `frontend/services/dto/task.ts`, add import and field: + +```typescript +import type { TaskDocument } from './task-document' +``` + +Add to `Task` type: + +```typescript +documents: TaskDocument[] +``` + +- [ ] **Step 3: Create task-documents service** + +```typescript +import type { TaskDocument } from './dto/task-document' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' +import { $fetch } from 'ofetch' + +export function useTaskDocumentService() { + const api = useApi() + const config = useRuntimeConfig() + const baseURL = config.public.apiBase || '/api' + + async function getByTask(taskId: number): Promise { + const data = await api.get>('/task_documents', { + task: `/api/tasks/${taskId}`, + }) + return extractHydraMembers(data) + } + + async function upload(taskId: number, file: File, onProgress?: (percent: number) => void): Promise { + const formData = new FormData() + formData.append('file', file) + formData.append('task', `/api/tasks/${taskId}`) + + return await $fetch(`${baseURL}/task_documents`, { + method: 'POST', + body: formData, + credentials: 'include', + // Do NOT set Content-Type — browser sets multipart boundary automatically + }) + } + + async function remove(id: number): Promise { + await api.delete(`/task_documents/${id}`, {}, { + toastSuccessKey: 'taskDocuments.deleted', + }) + } + + function getDownloadUrl(id: number): string { + return `${baseURL}/task_documents/${id}/download` + } + + return { getByTask, upload, remove, getDownloadUrl } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add frontend/services/dto/task-document.ts frontend/services/task-documents.ts frontend/services/dto/task.ts +git commit -m "feat(frontend) : add TaskDocument DTO and service" +``` + +--- + +### Task 9: i18n translations + +**Files:** +- Modify: `frontend/i18n/locales/fr.json` + +- [ ] **Step 1: Add translation keys** + +Add after the `"tasks"` block in `fr.json`: + +```json +"taskDocuments": { + "title": "Documents", + "dropzone": "Glisser des fichiers ici ou cliquer pour sélectionner", + "uploaded": "Document ajouté avec succès.", + "deleted": "Document supprimé avec succès.", + "uploadError": "Erreur lors de l'upload du document.", + "confirmDeleteTitle": "Supprimer le document", + "confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce document ?", + "download": "Télécharger", + "maxSizeError": "Le fichier dépasse la taille maximale de 50 Mo." +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(frontend) : add task documents i18n translations" +``` + +--- + +### Task 10: TaskDocumentUpload component + +**Files:** +- Create: `frontend/components/task/TaskDocumentUpload.vue` + +- [ ] **Step 1: Create the upload component** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/task/TaskDocumentUpload.vue +git commit -m "feat(frontend) : add TaskDocumentUpload drag & drop component" +``` + +--- + +### Task 11: TaskDocumentList component + +**Files:** +- Create: `frontend/components/task/TaskDocumentList.vue` + +- [ ] **Step 1: Create the document list component** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/task/TaskDocumentList.vue +git commit -m "feat(frontend) : add TaskDocumentList with thumbnails and icons" +``` + +--- + +### Task 12: TaskDocumentPreview modal + +**Files:** +- Create: `frontend/components/task/TaskDocumentPreview.vue` + +- [ ] **Step 1: Create the preview modal** + +```vue +