Compare commits
26 Commits
v0.4.5
...
b3f32e4c6c
| Author | SHA1 | Date | |
|---|---|---|---|
| b3f32e4c6c | |||
| 2dd0a97113 | |||
| 21436b04ca | |||
| 97871c21a0 | |||
| 4ae842bd1e | |||
| 4461a9c99a | |||
| 37f572bdbb | |||
| 3bdaf1e03c | |||
| 02c8578677 | |||
| 421cc72f42 | |||
| e2751410f8 | |||
| 4a0b7222d0 | |||
| 415ed4d3d8 | |||
| 15bdf64fc6 | |||
| 76c665cc62 | |||
| 78d81cc531 | |||
| d566d85c59 | |||
| bcd0cb6e75 | |||
| 4fd39f4b1a | |||
| 39ebd82884 | |||
| df6757051d | |||
| 0b93a00c8a | |||
| 33a81e6eb0 | |||
| 1fa7f32ed4 | |||
|
|
4184cadfe4 | ||
|
|
88a4916662 |
14
.env
14
.env
@@ -20,16 +20,4 @@ JWT_COOKIE_TTL=86400
|
||||
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||
|
||||
ENCRYPTION_KEY=change_me_in_env_local
|
||||
###> symfony/lock ###
|
||||
# Choose one of the stores below
|
||||
# postgresql+advisory://db_user:db_password@localhost/db_name
|
||||
LOCK_DSN=flock
|
||||
###< symfony/lock ###
|
||||
|
||||
###> symfony/messenger ###
|
||||
# Choose one of the transports below
|
||||
# MESSENGER_TRANSPORT_DSN=amqp://guest:guest@localhost:5672/%2f/messages
|
||||
# MESSENGER_TRANSPORT_DSN=redis://localhost:6379/messages
|
||||
MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0
|
||||
###< symfony/messenger ###
|
||||
ENCRYPTION_KEY=change_me_in_env_local
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
|
||||
|
||||
> **WIP — Intégration Mail (branche `feat/mail-integration`)** : client mail OVH IMAP. Avant de toucher au mail, lire `docs/mail-integration.md` (section « Statut & reprise » = bugs déjà corrigés, points en suspens, commandes). Code : `src/Mail/`, `src/Service/MailSyncService.php`, `src/Controller/Mail/`, `frontend/{services,stores,components}/mail*`.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16
|
||||
@@ -101,7 +99,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- 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: string | number | null }` — accepte les valeurs **string** (enums string OK, ex `category`/`StatusCategory`), pas seulement `number` (vérifié dans la source `Select.vue` : `modelValue: string | number | null`). L'option vide `null` n'est ajoutée que si `empty-option-label` est passé (ne pas le passer pour un champ requis). Largeur via `group-class` (pas de prop `minWidth`/`min-width`). ⚠️ Le `COMPONENTS.md` de la lib est inexact sur ce composant (il indique une clé `text` et une prop `minWidth` inexistantes) : la clé d'affichage réelle est `label`. Ne jamais modifier la lib `malio-layer-ui` depuis ce projet.
|
||||
- MalioSelect : options `{ label: string, value: number | null }` uniquement — pas de string values, utiliser `<select>` natif pour les enums string
|
||||
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
|
||||
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
|
||||
|
||||
|
||||
45
README.md
45
README.md
@@ -21,7 +21,6 @@ Application de gestion de projet avec suivi du temps et portail client.
|
||||
- Profil utilisateur avec avatar (crop circulaire)
|
||||
- Notifications temps réel
|
||||
- Intégration Gitea (issues, repos)
|
||||
- Intégration Mail IMAP (boîte partagée OVH, voir `docs/mail-integration.md`)
|
||||
- Serveur MCP pour assistants IA
|
||||
- Multi-langue (i18n)
|
||||
|
||||
@@ -45,10 +44,6 @@ make install
|
||||
|
||||
L'application est accessible sur **http://localhost:8082**.
|
||||
|
||||
Les valeurs par défaut du `.env` committé suffisent pour démarrer en local. Pour la prod
|
||||
(et pour activer la messagerie), surcharger les variables sensibles dans `.env.local` —
|
||||
voir « Variables d'environnement » ci-dessous.
|
||||
|
||||
### Comptes de test (fixtures)
|
||||
|
||||
| Utilisateur | Mot de passe | Rôle | Détails |
|
||||
@@ -60,25 +55,6 @@ voir « Variables d'environnement » ci-dessous.
|
||||
| `client-liot` | `client` | ROLE_CLIENT | Client LIOT (projet SIRH) |
|
||||
| `client-acme` | `client` | ROLE_CLIENT | Client ACME (projet CRM) |
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
Les variables sont définies dans `.env` (committé, valeurs par défaut pour le dev) et
|
||||
peuvent être surchargées dans `.env.local` (jamais committé). En prod, elles vont dans le
|
||||
`.env` du serveur (`/var/www/lesstime/.env`, voir `infra/prod/.env.example`).
|
||||
|
||||
| Variable | Rôle | Défaut dev | À fixer en prod |
|
||||
|----------|------|-----------|-----------------|
|
||||
| `APP_SECRET` | Secret Symfony | placeholder | ✅ (hex 32) |
|
||||
| `JWT_PASSPHRASE` | Passphrase des clés JWT | placeholder | ✅ |
|
||||
| `DATABASE_URL` | Connexion PostgreSQL | container `db` | ✅ (`host.docker.internal`) |
|
||||
| `CORS_ALLOW_ORIGIN` | Origines CORS autorisées | localhost | ✅ (domaine prod) |
|
||||
| **`ENCRYPTION_KEY`** | **Clé hex 32 bytes chiffrant les credentials IMAP/SMTP (feature mail)** | placeholder | ✅ — doit rester **stable**, sinon les credentials mail stockés deviennent illisibles |
|
||||
| **`LOCK_DSN`** | **Store de verrous Symfony pour la sync mail (anti-chevauchement)** | `flock` | `flock` suffit |
|
||||
|
||||
> **Messagerie** : `ENCRYPTION_KEY` et `LOCK_DSN` sont introduites par l'intégration mail.
|
||||
> Détails de config et cron de synchronisation : `docs/mail-integration.md` et `docs/mail-cron-setup.md`.
|
||||
> Générer une clé : `php -r "echo bin2hex(random_bytes(32));"`.
|
||||
|
||||
## Commandes
|
||||
|
||||
### Docker
|
||||
@@ -97,7 +73,6 @@ make shell-root # Shell root dans le container PHP
|
||||
make dev-nuxt # Dev server Nuxt (hot reload, port 3002)
|
||||
make cache-clear # Vider le cache Symfony
|
||||
make logs-dev # Tail logs Symfony
|
||||
make mail-sync # Synchroniser la boîte mail IMAP (voir docs/mail-cron-setup.md)
|
||||
```
|
||||
|
||||
### Base de données
|
||||
@@ -241,19 +216,13 @@ docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token
|
||||
|
||||
## Déploiement
|
||||
|
||||
La prod tourne en **Docker** : l'image est buildée par la CI Gitea sur push de tag `v*`
|
||||
(`gitea.malio.fr/malio-dev/lesstime:<tag>`), puis déployée par le script `deploy.sh` sur
|
||||
le serveur (dossier `/var/www/lesstime`, container `lesstime-app`).
|
||||
|
||||
```bash
|
||||
# Sur le serveur, depuis /var/www/lesstime
|
||||
sudo ./deploy.sh # déploie la dernière image (latest)
|
||||
sudo ./deploy.sh v0.4.2 # déploie une version précise
|
||||
```
|
||||
|
||||
Le script active la maintenance, pull l'image, redémarre le container, lance les migrations
|
||||
et vide le cache. Guide complet (première installation, BDD, Nginx, JWT, rollback) :
|
||||
**`doc/deployment-docker.md`**.
|
||||
1. Déployer le code sur le serveur
|
||||
2. `composer install --no-dev --optimize-autoloader`
|
||||
3. `php bin/console doctrine:migrations:migrate --no-interaction`
|
||||
4. `php bin/console cache:clear --env=prod`
|
||||
5. `cd frontend && npm install && npm run build:dist`
|
||||
6. `docker restart nginx-lesstime`
|
||||
7. Ouvrir le port 8082 sur le firewall (LAN uniquement)
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -21,15 +21,12 @@
|
||||
"sabre/vobject": "^4.5",
|
||||
"symfony/asset": "8.0.*",
|
||||
"symfony/console": "8.0.*",
|
||||
"symfony/doctrine-messenger": "^8.0",
|
||||
"symfony/dotenv": "8.0.*",
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/http-client": "8.0.*",
|
||||
"symfony/lock": "8.0.*",
|
||||
"symfony/mcp-bundle": "^0.6.0",
|
||||
"symfony/messenger": "^8.0",
|
||||
"symfony/mime": "8.0.*",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
"symfony/property-access": "8.0.*",
|
||||
@@ -39,8 +36,7 @@
|
||||
"symfony/security-bundle": "8.0.*",
|
||||
"symfony/serializer": "8.0.*",
|
||||
"symfony/validator": "8.0.*",
|
||||
"symfony/yaml": "8.0.*",
|
||||
"webklex/php-imap": "^6.2"
|
||||
"symfony/yaml": "8.0.*"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
@@ -97,8 +93,6 @@
|
||||
"require-dev": {
|
||||
"doctrine/doctrine-fixtures-bundle": "^4.3",
|
||||
"friendsofphp/php-cs-fixer": "^3.94",
|
||||
"phpunit/phpunit": "^13.0",
|
||||
"symfony/browser-kit": "^8.0",
|
||||
"symfony/css-selector": "^8.0"
|
||||
"phpunit/phpunit": "^13.0"
|
||||
}
|
||||
}
|
||||
|
||||
1343
composer.lock
generated
1343
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
framework:
|
||||
lock: '%env(LOCK_DSN)%'
|
||||
@@ -1,33 +0,0 @@
|
||||
framework:
|
||||
messenger:
|
||||
failure_transport: failed
|
||||
|
||||
transports:
|
||||
sync: 'sync://'
|
||||
|
||||
async:
|
||||
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
options:
|
||||
queue_name: default
|
||||
retry_strategy:
|
||||
max_retries: 3
|
||||
delay: 1000
|
||||
multiplier: 2
|
||||
max_delay: 0
|
||||
|
||||
failed: 'doctrine://default?queue_name=failed&auto_setup=0'
|
||||
|
||||
routing:
|
||||
# Sync à la demande (bouton « rafraîchir ») : exécutée pendant la requête HTTP
|
||||
# pour que le re-fetch du front voie immédiatement les nouveaux mails, sans worker
|
||||
# messenger:consume à maintenir. La sync de fond reste assurée par le cron OS
|
||||
# (app:mail:sync, synchrone, indépendant du bus). Repasser à `async` + worker si
|
||||
# la boîte grossit au point que la sync à la demande approche le timeout PHP.
|
||||
'App\Message\MailSyncRequested': sync
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
messenger:
|
||||
transports:
|
||||
async: 'in-memory://'
|
||||
failed: 'in-memory://'
|
||||
@@ -64,8 +64,6 @@ security:
|
||||
- { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||
- { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] }
|
||||
- { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY }
|
||||
# Mail : requiert authentification (les checks ROLE_USER/ROLE_CLIENT sont dans MailAccessChecker)
|
||||
- { path: ^/api/mail, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
|
||||
when@test:
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
framework:
|
||||
default_locale: en
|
||||
translator:
|
||||
default_path: '%kernel.project_dir%/translations'
|
||||
providers:
|
||||
@@ -301,7 +301,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* },
|
||||
* },
|
||||
* translator?: bool|array{ // Translator configuration
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* fallbacks?: list<scalar|Param|null>,
|
||||
* logging?: bool|Param, // Default: false
|
||||
* formatter?: scalar|Param|null, // Default: "translator.formatter.default"
|
||||
@@ -413,7 +413,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* },
|
||||
* lock?: bool|string|array{ // Lock configuration
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* resources?: array<string, string|list<scalar|Param|null>>,
|
||||
* },
|
||||
* semaphore?: bool|string|array{ // Semaphore configuration
|
||||
@@ -421,7 +421,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* resources?: array<string, scalar|Param|null>,
|
||||
* },
|
||||
* messenger?: bool|array{ // Messenger configuration
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* routing?: array<string, string|array{ // Default: []
|
||||
* senders?: list<scalar|Param|null>,
|
||||
* }>,
|
||||
@@ -1360,7 +1360,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* include_type?: bool|Param, // Always include @var in updates (including delete ones). // Default: false
|
||||
* },
|
||||
* messenger?: bool|array{
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* elasticsearch?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.4.5'
|
||||
app.version: '0.4.0'
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# Mail Integration — Configuration cron OS
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
La synchronisation IMAP est déclenchée par un cron OS toutes les 10 minutes.
|
||||
Elle appelle la commande Symfony `app:mail:sync` qui s'exécute **dans le container PHP**.
|
||||
|
||||
Un Symfony Lock (`mail.sync`, TTL 10 min, store `flock` via `LOCK_DSN=flock`) empêche
|
||||
les runs de se chevaucher si une sync prend plus de 10 min.
|
||||
|
||||
> **Dev vs prod** — en dev le container s'appelle `php-lesstime-fpm` et on passe par `make`.
|
||||
> En **production** le container s'appelle `lesstime-app` (service `app` du `docker-compose.yml`
|
||||
> dans `/var/www/lesstime`), il n'y a **pas de `make`** : tout passe par `docker compose` / `docker exec`.
|
||||
|
||||
## Prérequis
|
||||
|
||||
- `MailConfiguration.enabled = true` (configurable depuis l'admin — onglet « Mail »)
|
||||
- `ENCRYPTION_KEY` (clé hex 32 bytes) défini dans l'environnement :
|
||||
- **dev** : `infra/dev/.env.docker.local`
|
||||
- **prod** : `/var/www/lesstime/.env`
|
||||
- Container démarré :
|
||||
- **dev** : `make start` (container `php-lesstime-fpm`)
|
||||
- **prod** : déployé via `sudo ./deploy.sh` (container `lesstime-app`)
|
||||
|
||||
## Variables d'environnement nécessaires
|
||||
|
||||
| Variable | Description | Exemple |
|
||||
|---|---|---|
|
||||
| `ENCRYPTION_KEY` | Clé hex 32 bytes pour déchiffrer le password IMAP | `$(php -r "echo bin2hex(random_bytes(32));")` |
|
||||
| `LOCK_DSN` | DSN du store de verrous Symfony | `flock` (défaut, fichier local) |
|
||||
|
||||
La clé `ENCRYPTION_KEY` doit être **identique** à celle utilisée pour chiffrer le password
|
||||
lors de la configuration depuis l'admin. Si elle change, les credentials stockés deviennent illisibles.
|
||||
|
||||
---
|
||||
|
||||
## Dev
|
||||
|
||||
### Lancer une sync à la main
|
||||
|
||||
```bash
|
||||
make mail-sync # sync complète (toutes les boîtes)
|
||||
make mail-sync FOLDER=INBOX # un seul dossier (doit déjà exister en base)
|
||||
make mail-sync DRYRUN=1 # simulation (dry-run, pas d'écriture BDD)
|
||||
```
|
||||
|
||||
Ou directement dans le container :
|
||||
|
||||
```bash
|
||||
docker exec php-lesstime-fpm php bin/console app:mail:sync
|
||||
docker exec php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX
|
||||
docker exec php-lesstime-fpm php bin/console app:mail:sync --dry-run
|
||||
```
|
||||
|
||||
### Logs (dev)
|
||||
|
||||
```bash
|
||||
make logs-dev # tail -f var/log/dev.log
|
||||
```
|
||||
|
||||
Les messages loggés par `MailSyncService` sont préfixés `mail.sync`.
|
||||
|
||||
---
|
||||
|
||||
## Production
|
||||
|
||||
En prod, l'app tourne dans le container `lesstime-app` déployé par `sudo ./deploy.sh`
|
||||
(dossier `/var/www/lesstime`). La commande s'exécute en tant que `www-data` (uid 33),
|
||||
comme les migrations lancées par `deploy.sh`.
|
||||
|
||||
### Lancer une sync à la main
|
||||
|
||||
Depuis `/var/www/lesstime` :
|
||||
|
||||
```bash
|
||||
sudo docker compose exec -T -u www-data app php bin/console app:mail:sync
|
||||
sudo docker compose exec -T -u www-data app php bin/console app:mail:sync --folder=INBOX
|
||||
sudo docker compose exec -T -u www-data app php bin/console app:mail:sync --dry-run
|
||||
```
|
||||
|
||||
### Installer le cron
|
||||
|
||||
Sur la **machine hôte** (pas dans le container). Comme `docker` requiert `sudo` en prod,
|
||||
installer le cron sous root :
|
||||
|
||||
```bash
|
||||
sudo crontab -e
|
||||
```
|
||||
|
||||
Ajouter :
|
||||
|
||||
```cron
|
||||
*/10 * * * * cd /var/www/lesstime && docker compose exec -T -u www-data app php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1
|
||||
```
|
||||
|
||||
> Le crontab de root exécute déjà les commandes en root → pas de `sudo` à l'intérieur de la ligne cron.
|
||||
> La commande est **idempotente** (UIDs uniques en base) : la relancer ne duplique pas les données.
|
||||
|
||||
### Logs (prod)
|
||||
|
||||
```bash
|
||||
cd /var/www/lesstime
|
||||
docker compose logs -f --tail=100 app # logs container
|
||||
docker compose exec app cat var/log/prod.log # log Symfony (volume lesstime_logs)
|
||||
```
|
||||
|
||||
### Checklist setup production
|
||||
|
||||
1. [ ] Définir `ENCRYPTION_KEY` (hex 32 bytes) et `LOCK_DSN=flock` dans `/var/www/lesstime/.env`
|
||||
2. [ ] Créer le compte mail dédié (ex: `lesstime@votre-domaine.fr`) chez OVH
|
||||
3. [ ] Accéder à `/admin` → onglet « Mail » → renseigner les credentials IMAP/SMTP
|
||||
4. [ ] Cliquer « Tester la connexion » → vérifier le succès
|
||||
5. [ ] Cocher « Activer la synchronisation » → Enregistrer
|
||||
6. [ ] Lancer une sync manuelle pour valider (commande ci-dessus)
|
||||
7. [ ] Installer le cron OS (voir « Installer le cron »)
|
||||
8. [ ] Vérifier les logs après la première sync (`docker compose logs -f app`, chercher `mail.sync`)
|
||||
|
||||
---
|
||||
|
||||
## Sécurité
|
||||
|
||||
- Le password IMAP est **toujours stocké chiffré** (libsodium secretbox)
|
||||
- Les corps de mails, passwords et pièces jointes ne sont **jamais loggés**
|
||||
- Le lock `flock` évite les runs parallèles (fichier dans `/tmp/sf.mail.sync.<hash>.lock`)
|
||||
- La page `/mail` et tous les endpoints `/api/mail/*` sont refusés aux `ROLE_CLIENT` exclusifs
|
||||
- Le sidebar « Messagerie » est masqué pour les utilisateurs `ROLE_CLIENT` sans `ROLE_USER`
|
||||
- Les corps de mails sont sanitisés via DOMPurify avant affichage (`frontend/utils/sanitizeMailHtml.ts`)
|
||||
- Les pixels de tracking distants sont remplacés par un placeholder
|
||||
@@ -1,147 +0,0 @@
|
||||
# Intégration Mail — Vue d'ensemble
|
||||
|
||||
> ## 🟢 Statut & reprise (handoff — MAJ 2026-05-20)
|
||||
>
|
||||
> **Branche** : `feat/mail-integration` · **MR Gitea** : https://gitea.malio.fr/MALIO-DEV/Lesstime/pulls/5 (base `develop`)
|
||||
> Construit en 7 phases (plans dans `docs/superpowers/plans/2026-05-19-mail-phase*.md`).
|
||||
>
|
||||
> ### Ce qui marche (testé contre une vraie boîte OVH `contact@malio.fr`)
|
||||
> - Connexion IMAP + test connexion (admin → `/admin` onglet Mail)
|
||||
> - Synchro complète multi-dossiers : **456 messages / 57 dossiers** ramenés, ne crashe plus
|
||||
> - Lecture dossiers/messages dans `/mail`, arbre repliable (chevrons, sous-dossiers masqués par défaut)
|
||||
> - Lecture d'un mail, sanitization DOMPurify
|
||||
> - Création/lien tâche depuis un mail
|
||||
>
|
||||
> ### Bugs déjà corrigés ce soir (NE PAS ré-investiguer)
|
||||
> Tous dans `ImapMailProvider` / `MailSyncService` — les tests mockaient le provider, donc le fetch réel n'avait jamais été exercé avant le test live :
|
||||
> 1. Requête sans critère → `BAD parse error: zero-length content` → `whereAll()`
|
||||
> 2. `getDate()`/`getSubject()` renvoient des `Attribute` webklex v6 → casts explicites
|
||||
> 3. Séquence par défaut `ST_MSGN` → `peek()` faisait un STORE rejeté par OVH (`flag could not be removed`) → forcé `ST_UID` partout
|
||||
> 4. Snippet via `getTextBody()` = fetch du corps de chaque mail (sync 179s + peek) → `setFetchBody(false)`, snippet désactivé au listing
|
||||
> 5. Test connexion exigeait `enabled=true` → découplé via `getClient(requireEnabled:false)` + `testConnection()`
|
||||
> 6. Contrainte UNIQUE globale sur `message_id` → fausse pour IMAP (même Message-ID dans plusieurs dossiers) → fermait l'EntityManager → cascade. **Migration `Version20260520061736`** : index simple. Garde anti-cascade dans `MailSyncService` (reset `ManagerRegistry`).
|
||||
> 7. 139 connexions IMAP (une/dossier) → throttling OVH → réutilisation d'1 connexion (`closeConnection()` sur l'interface) + reconnexion ciblée après dossier en erreur.
|
||||
> - Contrat front/back réaligné dans `frontend/services/mail.ts` (route `/mail/folders/{path}/messages`, mapping `messages→items`, `fromAddress→fromEmail`, détail plat→imbriqué).
|
||||
>
|
||||
> ### Points en suspens / à savoir
|
||||
> - **Mise à jour auto** = cron OS lançant `make mail-sync` toutes les 10 min (cf `docs/mail-cron-setup.md`). **Pas configuré en dev** — lancer à la main.
|
||||
> - **Bouton "Actualiser"** : dispatch async Messenger (`MailSyncRequested → async`). Sans worker `messenger:consume async` qui tourne, les demandes s'empilent sans s'exécuter. En prod : supervisor. En dev : lancer un worker.
|
||||
> - **~7 dossiers/139** à encodage spécial (ex: `INBOX/RH/.../SÉBASTIEN` en UTF7-modifié) ou réponses vides sont skippés proprement et réessayés au cycle suivant. Edge case webklex non bloquant.
|
||||
> - **Dépendance** : `webklex/php-imap ^6.2` tire des paquets Laravel (`illuminate/*` via `carbon ^3`) dans ce projet Symfony — fonctionnel mais à valider en review.
|
||||
> - 6 PHPUnit Notices (mocks sans expectations) non bloquantes.
|
||||
>
|
||||
> ### Commandes utiles
|
||||
> ```bash
|
||||
> make mail-sync # synchro complète
|
||||
> docker exec -i -u www-data php-lesstime-fpm php bin/console app:mail:sync --folder=INBOX -v
|
||||
> docker exec -i -u www-data php-lesstime-fpm php bin/console messenger:consume async -vv # worker (fait marcher le bouton)
|
||||
> make test # 33 tests
|
||||
> ```
|
||||
> Fixtures `make fixtures` plantent sur un état legacy `workflow_id` (hors-scope mail) — configurer la boîte via l'UI admin.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- Lecture de la boîte mail partagée (IMAP) depuis Lesstime
|
||||
- Navigation par dossiers (arbre récursif avec compteurs non-lus)
|
||||
- Liste paginée des messages (infinite scroll, cursor-based)
|
||||
- Lecture des corps de mail sanitisés (DOMPurify — protection XSS + pixels tracking)
|
||||
- Création d'une tâche Lesstime depuis un mail (sujet → titre, texte → description)
|
||||
- Lien mail ↔ tâche (bidirectionnel)
|
||||
- Onglet "Mails" dans le TaskDrawer pour retrouver les mails liés à une tâche
|
||||
- Synchronisation IMAP automatique via cron OS (toutes les 10 min)
|
||||
- Déclenchement manuel de sync depuis l'UI (bouton Refresh)
|
||||
- Badge non-lus en temps réel dans la sidebar (polling 30s)
|
||||
|
||||
## Endpoints API
|
||||
|
||||
| Méthode | URL | Rôle | Description |
|
||||
|---------|-----|------|-------------|
|
||||
| GET | `/api/mail/configuration` | ROLE_ADMIN | Lire la config singleton |
|
||||
| PATCH | `/api/mail/configuration` | ROLE_ADMIN | Mettre à jour la config |
|
||||
| POST | `/api/mail/configuration/test` | ROLE_ADMIN | Tester la connexion IMAP |
|
||||
| GET | `/api/mail/folders` | ROLE_USER | Arbre des dossiers + unread |
|
||||
| GET | `/api/mail/messages` | ROLE_USER | Liste paginée (param: folder, cursor, limit) |
|
||||
| GET | `/api/mail/messages/{id}` | ROLE_USER | Détail + body (cached 5 min) |
|
||||
| POST | `/api/mail/messages/{id}/read` | ROLE_USER | Marquer lu/non-lu |
|
||||
| POST | `/api/mail/messages/{id}/flag` | ROLE_USER | Marquer étoilé/non-étoilé |
|
||||
| POST | `/api/mail/messages/{id}/create-task` | ROLE_USER | Créer tâche depuis mail |
|
||||
| POST | `/api/mail/messages/{id}/link-task` | ROLE_USER | Lier mail à tâche existante |
|
||||
| DELETE | `/api/mail/messages/{id}/link-task/{taskId}` | ROLE_USER | Supprimer le lien |
|
||||
| GET | `/api/tasks/{id}/mails` | ROLE_USER | Mails liés à une tâche |
|
||||
| GET | `/api/mail/attachments/{id}` | ROLE_USER | Télécharger une pièce jointe |
|
||||
| POST | `/api/mail/sync` | ROLE_USER | Déclencher sync async (Messenger) |
|
||||
|
||||
Tous les endpoints `/api/mail/*` refusent explicitement `ROLE_CLIENT`.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- ROLE_CLIENT exclusif : accès refusé à tous les endpoints mail et à la page `/mail`
|
||||
- Le sidebar "Messagerie" est masqué pour les ROLE_CLIENT
|
||||
- Password IMAP chiffré via libsodium secretbox (env `ENCRYPTION_KEY`)
|
||||
- Corps de mail sanitisés via DOMPurify (`sanitizeMailHtml.ts`) — script/iframe/object/embed/on*/javascript: bloqués
|
||||
- Pixels tracking distants (img src http) remplacés par placeholder
|
||||
- Aucun body, password ou contenu de pièce jointe dans les logs
|
||||
|
||||
## Dépendances
|
||||
|
||||
### Backend
|
||||
- `webklex/php-imap` : client IMAP PHP
|
||||
- `symfony/lock` : Symfony Lock pour éviter les syncs parallèles
|
||||
- `symfony/messenger` : dispatch asynchrone `MailSyncRequested`
|
||||
- `libsodium` (ext PHP) : chiffrement du password IMAP
|
||||
|
||||
### Frontend
|
||||
- `dompurify` + `@types/dompurify` : sanitization HTML des corps de mail
|
||||
|
||||
## Fichiers clés
|
||||
|
||||
### Backend
|
||||
- `src/Entity/MailConfiguration.php` — entité singleton (credentials, enabled)
|
||||
- `src/Entity/MailFolder.php` — dossier IMAP synced
|
||||
- `src/Entity/MailMessage.php` — message IMAP synced (headers, flags)
|
||||
- `src/Entity/TaskMailLink.php` — lien tâche ↔ mail
|
||||
- `src/Mail/ImapMailProvider.php` — implémentation IMAP (webklex)
|
||||
- `src/Service/MailSyncService.php` — algorithme de sync (UID FETCH, resync flags)
|
||||
- `src/Controller/Mail/` — controllers custom (test, folders, messages, sync)
|
||||
- `src/State/Mail/` — providers/processors API Platform (configuration)
|
||||
|
||||
### Frontend
|
||||
- `frontend/pages/mail.vue` — page principale 3 colonnes
|
||||
- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton
|
||||
- `frontend/components/admin/AdminMailTab.vue` — onglet config admin
|
||||
- `frontend/stores/mail.ts` — store Pinia (folders, messages, polling)
|
||||
- `frontend/services/mail.ts` — service API (toutes les méthodes)
|
||||
- `frontend/services/dto/mail.ts` — types TypeScript
|
||||
- `frontend/utils/sanitizeMailHtml.ts` — DOMPurify wrapper
|
||||
|
||||
## Synchronisation cron
|
||||
|
||||
Voir `docs/mail-cron-setup.md` pour la configuration détaillée.
|
||||
|
||||
Résumé :
|
||||
|
||||
```bash
|
||||
# Cron OS (toutes les 10 min)
|
||||
*/10 * * * * cd /path/to/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1
|
||||
|
||||
# Commandes Makefile
|
||||
make mail-sync # Sync complète
|
||||
make mail-sync FOLDER=INBOX # Sync d'un dossier
|
||||
make mail-sync DRYRUN=1 # Simulation sans écriture
|
||||
```
|
||||
|
||||
## Configuration admin
|
||||
|
||||
1. Aller sur `/admin` → onglet "Mail"
|
||||
2. Renseigner les credentials IMAP/SMTP (OVH : `ssl0.ovh.net`, port 993/465, SSL)
|
||||
3. Cliquer "Tester la connexion"
|
||||
4. Activer la synchronisation → Enregistrer
|
||||
5. Configurer le cron OS
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
| Variable | Description | Obligatoire |
|
||||
|----------|-------------|-------------|
|
||||
| `ENCRYPTION_KEY` | Clé hex 32 bytes libsodium pour chiffrer le password IMAP | Oui |
|
||||
| `LOCK_DSN` | DSN Symfony Lock (défaut: `flock`) | Non |
|
||||
| `MESSENGER_TRANSPORT_DSN` | Transport Messenger pour sync async | Recommandé (prod) |
|
||||
@@ -1,264 +0,0 @@
|
||||
# Mail Integration — Master Plan
|
||||
|
||||
> **Master plan** : ce document décrit le découpage en phases. Chaque phase aura son propre plan détaillé (rédigé par un subagent rédacteur) puis sera implémentée par un subagent codeur, en cycle.
|
||||
|
||||
**Spec source** : `docs/superpowers/specs/2026-05-19-mail-integration-design.md`
|
||||
|
||||
**Goal** : Ajouter à Lesstime un client mail intégré pour une boîte partagée OVH (IMAP/SMTP), avec lecture inbox/dossiers et création/lien tâche depuis un mail.
|
||||
|
||||
**Stratégie** : 7 phases séquentielles, dépendances claires, chaque phase = working software testable. Cycle par phase : rédacteur → codeur → review humaine → phase suivante.
|
||||
|
||||
---
|
||||
|
||||
## Cartographie des phases
|
||||
|
||||
```
|
||||
Phase 1 (Backend foundations) ──┐
|
||||
├─→ Phase 2 (IMAP provider + sync) ──┐
|
||||
│ ├─→ Phase 3 (API backend) ──┐
|
||||
│ │ │
|
||||
└─→─────────────────────────────────────────────────────────────────┤
|
||||
│
|
||||
Phase 4 (Frontend services + store) ←──────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
├─→ Phase 5 (UI principale 3 colonnes)
|
||||
│
|
||||
├─→ Phase 6 (Intégration tâches : modals, onglet TaskDrawer)
|
||||
│
|
||||
└─→ Phase 7 (Admin config + sidebar + polish)
|
||||
```
|
||||
|
||||
Chaque phase produit du logiciel fonctionnel (testable, mergeable) sans casser les précédentes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Backend Foundations
|
||||
|
||||
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase1-foundations.md`
|
||||
|
||||
**Scope** :
|
||||
- Entité `MailConfiguration` (singleton, fields complets de la spec, `encryptedPassword` via `TokenEncryptor`)
|
||||
- Entité `MailFolder`
|
||||
- Entité `MailMessage`
|
||||
- Entité `TaskMailLink` (avec unique constraint)
|
||||
- Repositories : `MailConfigurationRepository::findSingleton()`, `MailFolderRepository`, `MailMessageRepository`, `TaskMailLinkRepository`
|
||||
- Migration Doctrine unique créant les 4 tables (raw SQL)
|
||||
- DTOs sous `src/Mail/Dto/` : `MailFolderDto`, `MailMessageHeaderDto`, `MailMessageDetailDto`, `MailAttachmentDto`
|
||||
- Interface `App\Mail\MailProviderInterface` (signatures uniquement, pas d'impl)
|
||||
- Exception `App\Mail\Exception\MailProviderException`
|
||||
- Tests unitaires repositories (au moins le pattern singleton)
|
||||
|
||||
**Critère d'acceptation** :
|
||||
- `make migration-migrate` passe sans erreur
|
||||
- `php bin/console doctrine:schema:validate` OK
|
||||
- `make test` vert (au moins les tests créés)
|
||||
- Fixture `MailConfiguration` désactivée (OVH defaults) ajoutée
|
||||
|
||||
**Dépendances** : aucune (point d'entrée).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — IMAP Provider + Sync
|
||||
|
||||
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase2-imap-sync.md`
|
||||
|
||||
**Scope** :
|
||||
- Ajout dépendance Composer `webklex/php-imap` (vérifier compat PHP 8.4)
|
||||
- Implémentation `App\Mail\ImapMailProvider implements MailProviderInterface`
|
||||
- Lecture config via `MailConfigurationRepository::findSingleton()`
|
||||
- Déchiffrement password via `TokenEncryptor`
|
||||
- `listFolders`, `listMessages`, `fetchMessage`, `markRead`, `markFlagged`, `moveMessage`, `fetchAttachment`
|
||||
- Wrapping erreurs en `MailProviderException`
|
||||
- `App\Service\MailSyncService`
|
||||
- `syncAll(): MailSyncReport`
|
||||
- `syncFolder(string $folderPath): MailSyncReport`
|
||||
- `syncFolderStructure(): void`
|
||||
- Algorithme exact de la spec (UID FETCH lastUid+1:*, resync flags N=200 derniers, detect suppressions avec garde 50%)
|
||||
- DTO `MailSyncReport` (count créés / mis à jour / supprimés / errors)
|
||||
- Symfony Lock (`mail.sync`, TTL 10 min)
|
||||
- Commande console `app:mail:sync` (avec option `--folder=...`)
|
||||
- Documentation cron OS + cible Makefile `make mail-sync`
|
||||
- Tests : ImapMailProvider mocké via fixture serveur ou interface, MailSyncService avec provider mocké
|
||||
|
||||
**Critère d'acceptation** :
|
||||
- `php bin/console app:mail:sync --dry-run` fonctionne contre une fake config
|
||||
- Tests `make test` verts
|
||||
- `make mail-sync` documentée dans Makefile
|
||||
|
||||
**Dépendances** : Phase 1.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — API Backend
|
||||
|
||||
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase3-api.md`
|
||||
|
||||
**Scope** :
|
||||
- API Platform ressources :
|
||||
- `GET /api/mail/configuration` (ROLE_ADMIN) — singleton provider
|
||||
- `PATCH /api/mail/configuration` (ROLE_ADMIN) — processor (jamais retourner password en clair, accepter nouveau password à chiffrer)
|
||||
- Custom controllers (priority: 1) :
|
||||
- `POST /api/mail/configuration/test` (ROLE_ADMIN) — test connexion
|
||||
- `GET /api/mail/folders` (ROLE_USER, refus ROLE_CLIENT explicite) — arbre + unreadCount depuis BDD
|
||||
- `GET /api/mail/folders/{path}/messages?page&limit` — pagination cursor `sentAt DESC, id DESC`
|
||||
- `GET /api/mail/messages/{id}` — fetch live IMAP + cache Symfony `mail_body_{messageId}` TTL 5 min
|
||||
- `POST /api/mail/messages/{id}/read` (body `{ read: bool }`)
|
||||
- `POST /api/mail/messages/{id}/flag`
|
||||
- `POST /api/mail/messages/{id}/create-task` (body `{ projectId, taskGroupId?, priority? }`)
|
||||
- `POST /api/mail/messages/{id}/link-task` (body `{ taskId }`)
|
||||
- `DELETE /api/mail/messages/{id}/link-task/{taskId}`
|
||||
- `GET /api/tasks/{id}/mails`
|
||||
- `GET /api/mail/attachments/{id}` — stream, `Content-Disposition: attachment`, jamais inline
|
||||
- `POST /api/mail/sync` — async via Messenger
|
||||
- Message + Handler Symfony Messenger `MailSyncRequested`
|
||||
- Sécurité : `#[IsGranted('IS_AUTHENTICATED_FULLY')]` + check `ROLE_USER && !ROLE_CLIENT` explicite
|
||||
- Tests fonctionnels endpoints (auth, format réponses, ROLE_CLIENT refusé)
|
||||
|
||||
**Critère d'acceptation** :
|
||||
- Tous endpoints répondent corrects status/format
|
||||
- Tests `make test` verts
|
||||
- ROLE_CLIENT refusé sur 100% des endpoints mail
|
||||
- Password jamais leak dans les réponses
|
||||
|
||||
**Dépendances** : Phase 1, Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Frontend Services + Store
|
||||
|
||||
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase4-frontend-services.md`
|
||||
|
||||
**Scope** :
|
||||
- Install npm `dompurify` + types
|
||||
- `frontend/services/dto/mail.ts` : tous les types TS
|
||||
- `frontend/services/mail.ts` : méthodes API (suivre pattern `tasks.ts`)
|
||||
- `listFolders`, `listMessages`, `getMessage`, `markRead`, `markFlagged`
|
||||
- `createTaskFromMail`, `linkTask`, `unlinkTask`, `listMailsForTask`
|
||||
- `triggerSync`
|
||||
- `getConfiguration`, `updateConfiguration`, `testConfiguration`
|
||||
- `downloadAttachment` (retourne Blob)
|
||||
- Store Pinia `frontend/stores/useMailStore.ts`
|
||||
- State : `folders`, `selectedFolderPath`, `messages[]`, `selectedMessageId`, `selectedMessageDetail`, `loading`, `syncing`, `globalUnreadCount`
|
||||
- Actions correspondantes
|
||||
- Polling `pollUnreadCount()` toutes les 30s (start/stop)
|
||||
- Sanitization helper `frontend/utils/sanitizeMailHtml.ts` (DOMPurify avec config bloquante : script/iframe/object/embed/on*/javascript:, strip ou placeholder pour `<img src="http(s)://...">` distants)
|
||||
|
||||
**Critère d'acceptation** :
|
||||
- `cd frontend && npx tsc --noEmit` OK
|
||||
- Test manuel d'un appel `mail.listFolders()` depuis devtools renvoie 401 si pas authentifié, 200 sinon
|
||||
|
||||
**Dépendances** : Phase 3 (les endpoints doivent exister).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — UI principale (page /mail)
|
||||
|
||||
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase5-ui-main.md`
|
||||
|
||||
**Scope** :
|
||||
- Page `frontend/pages/mail.vue` — layout 3 colonnes (dossiers / liste / lecteur), responsive
|
||||
- Composants `frontend/components/mail/` :
|
||||
- `MailFolderTree.vue` — arbre récursif avec badges unread, sélection
|
||||
- `MailMessageList.vue` — liste paginée (infinite scroll), indicateurs lu/étoilé/PJ, formatage relatif des dates
|
||||
- `MailMessageViewer.vue` — header (de/à/cc/date) + body sanitizé via DOMPurify + liste PJ téléchargeables + actions (Créer tâche / Lier / Marquer lu/non-lu / Étoiler)
|
||||
- `MailRefreshButton.vue` — bouton sync manuel, désactivé pendant `syncing`
|
||||
- i18n clés `mail.*` dans `frontend/i18n/locales/fr.json` (et `en.json` si présent) : titres, vides, actions, erreurs
|
||||
- Mapping noms dossiers système (`INBOX`, `Sent`, `Drafts`, `Archive`, `Trash`, `Junk`) → labels traduits
|
||||
- Gestion query param `?messageId=X` pour deep-link vers un mail (selection auto à l'ouverture)
|
||||
- Refus visuel pour ROLE_CLIENT (le middleware backend bloque déjà, mais ajouter check côté router/middleware Nuxt)
|
||||
|
||||
**Critère d'acceptation** :
|
||||
- Page accessible à `/mail` pour ROLE_USER/ROLE_ADMIN
|
||||
- ROLE_CLIENT redirigé vers `/portal`
|
||||
- Pas d'XSS via body mail (test manuel avec un mail contenant `<script>alert(1)</script>`)
|
||||
- Pixels tracking distants remplacés par placeholder
|
||||
|
||||
**Dépendances** : Phase 4.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Intégration Tâches
|
||||
|
||||
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase6-task-integration.md`
|
||||
|
||||
**Scope** :
|
||||
- `frontend/components/mail/MailCreateTaskModal.vue` — wrapper du `TaskDrawer` existant pré-rempli :
|
||||
- Titre = subject
|
||||
- Description = body plain text
|
||||
- Picker projet + groupe + priorité
|
||||
- À la création : appelle `POST /api/mail/messages/{id}/create-task`, ferme modal, redirige ou affiche succès
|
||||
- `frontend/components/mail/MailLinkTaskModal.vue` — autocomplete sur tâches existantes (filter par projet, statut non-archivé)
|
||||
- Onglet **"Mails"** sur `TaskDrawer.vue` :
|
||||
- Nouvelle section affichée à côté Documents / Time tracking / etc.
|
||||
- Liste `MailMessage` liés à la tâche (via `GET /api/tasks/{id}/mails`)
|
||||
- Item cliquable → `router.push('/mail?messageId=' + id)`
|
||||
- Bouton "Lier un mail" → ouvre un picker mail (TBD selon ergonomie : modal recherche ou redirige vers /mail)
|
||||
- Tests manuels : créer tâche depuis mail, lier mail à tâche existante, voir mail depuis onglet tâche
|
||||
|
||||
**Critère d'acceptation** :
|
||||
- Workflow complet : mail → "Créer tâche" → tâche créée et liée → visible dans onglet "Mails" du TaskDrawer
|
||||
- Workflow : tâche existante → "Lier mail" → mail apparaît dans onglet
|
||||
|
||||
**Dépendances** : Phase 5.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Admin Config + Sidebar + Polish
|
||||
|
||||
**Plan détaillé attendu** : `docs/superpowers/plans/2026-05-19-mail-phase7-admin-polish.md`
|
||||
|
||||
**Scope** :
|
||||
- `frontend/components/admin/AdminMailTab.vue` (calqué sur `AdminZimbraTab.vue`) :
|
||||
- Form : protocol (imap pour MVP), imapHost/Port/Encryption, smtpHost/Port/Encryption, username, password (write-only, `hasPassword: true` côté GET), sentFolderPath, enabled toggle
|
||||
- Bouton "Tester la connexion" → `POST /api/mail/configuration/test`
|
||||
- Indicateur OVH defaults pré-remplis (`ssl0.ovh.net:993/465`)
|
||||
- Ajout onglet `AdminMailTab` dans la page admin (selon pattern existant)
|
||||
- Lien sidebar dans le layout default :
|
||||
- Icône `material-symbols:mail-outline`
|
||||
- Label traduit
|
||||
- Badge unread (count `useMailStore.globalUnreadCount`)
|
||||
- Visible uniquement pour `ROLE_USER && !ROLE_CLIENT`
|
||||
- Lifecycle polling 30s : start dans `app.vue` ou layout default, stop au logout
|
||||
- Documentation finale :
|
||||
- README ou `docs/` : section "Mail integration" (cron OS, variables config, sécurité)
|
||||
- Makefile : `make mail-sync` documentée
|
||||
- Vérification finale tracking pixels (relire `sanitizeMailHtml.ts` + tester)
|
||||
- QA passe : workflow end-to-end depuis vraie boîte OVH (si dispo) ou IMAP test (greenmail/dovecot local)
|
||||
|
||||
**Critère d'acceptation** :
|
||||
- Admin peut configurer la boîte, tester, activer
|
||||
- Sidebar affiche badge unread temps réel (30s polling)
|
||||
- Doc d'install à jour
|
||||
- Aucun warning console front, aucun ERROR PHP dans `make logs-dev`
|
||||
|
||||
**Dépendances** : Phase 5 (sidebar utilise le store), Phase 3 (admin API).
|
||||
|
||||
---
|
||||
|
||||
## Conventions communes à toutes les phases
|
||||
|
||||
- **TDD** : test rouge → code → test vert → commit
|
||||
- **Strict types** PHP (`declare(strict_types=1)`) en tête de chaque fichier
|
||||
- **PHP CS Fixer** : `make php-cs-fixer-allow-risky` avant chaque commit
|
||||
- **Commits** : format `<type>(mail) : <message>` (espace avant `:`)
|
||||
- **Branche** : `feat/mail-integration` (créée au début de Phase 1)
|
||||
- **Pas de jamais logger** : bodies, password, attachments
|
||||
- **Review humaine entre chaque phase** : le user valide avant lancement phase suivante
|
||||
|
||||
---
|
||||
|
||||
## Cycle d'exécution
|
||||
|
||||
Pour chaque phase N :
|
||||
|
||||
1. **Spawn subagent rédacteur** (`feature-dev:code-architect`)
|
||||
- Input : ce master plan + spec + scope phase N
|
||||
- Output : `docs/superpowers/plans/2026-05-19-mail-phaseN-*.md` au format `writing-plans` (tasks bite-sized, fichiers exacts, code complet, commandes test)
|
||||
|
||||
2. **Spawn subagent codeur** (`ruflo-core:coder`)
|
||||
- Input : plan détaillé phase N
|
||||
- Output : code + tests + commits (TDD strict)
|
||||
|
||||
3. **Review humaine** : user valide ou demande corrections
|
||||
|
||||
4. **Phase suivante** uniquement si OK
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,526 +0,0 @@
|
||||
# Mail Integration — Phase 7 : Admin Config + Sidebar + Polish
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Finaliser l'intégration mail avec l'UI admin de configuration, le lien sidebar avec badge unread temps réel (polling 30s), et la documentation utilisateur/opérationnelle finale.
|
||||
|
||||
**Architecture:** Onglet `AdminMailTab.vue` calqué sur `AdminZimbraTab.vue` (form IMAP/SMTP/credentials, bouton test connexion). Lien sidebar dans `layouts/default.vue` (visible ROLE_USER+ROLE_ADMIN seulement, masqué ROLE_CLIENT pur). Polling start au login / stop au logout via layout. Documentation finale dans `docs/` + section README mail.
|
||||
|
||||
**Tech Stack:** Nuxt 4, Vue 3 Composition API, @malio/layer-ui, Pinia (useMailStore).
|
||||
|
||||
---
|
||||
|
||||
## Fichiers créés / modifiés
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `frontend/components/admin/AdminMailTab.vue` | **Créer** |
|
||||
| `frontend/pages/admin.vue` | **Modifier** (ajout onglet mail) |
|
||||
| `frontend/layouts/default.vue` | **Modifier** (lien sidebar + polling lifecycle) |
|
||||
| `frontend/i18n/locales/fr.json` | **Modifier** (clés mail.admin.* + mail.sidebar.*) |
|
||||
| `frontend/i18n/locales/en.json` | **Modifier si présent** |
|
||||
| `docs/mail-cron-setup.md` | **Modifier** (enrichir checklist prod + sécurité) |
|
||||
| `docs/mail-integration.md` | **Créer** (doc complète intégration) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Composant `AdminMailTab.vue`
|
||||
|
||||
**Fichier cible :** `frontend/components/admin/AdminMailTab.vue`
|
||||
|
||||
**Modèle de référence :** `frontend/components/admin/AdminZimbraTab.vue` — reproduire exactement le même pattern (reactive form, hasPassword, isSaving/isTesting, loadSettings onMounted, handleSave/handleTest).
|
||||
|
||||
**Service à utiliser :** `useMailService()` depuis `~/services/mail` — méthodes `getConfiguration`, `updateConfiguration`, `testConfiguration`.
|
||||
|
||||
**DTOs :** `MailConfigurationDto`, `MailConfigurationUpdateDto`, `MailTestConnectionResultDto` depuis `~/services/dto/mail`.
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] Créer `frontend/components/admin/AdminMailTab.vue`
|
||||
- [ ] Déclarer le reactive form avec tous les champs de `MailConfigurationDto` (sauf `hasPassword`, qui est en lecture seule) :
|
||||
```
|
||||
protocol: '' (lecture seule "imap" en MVP — champ disabled)
|
||||
imapHost: ''
|
||||
imapPort: 993 (default OVH)
|
||||
imapEncryption: 'ssl' (default OVH)
|
||||
smtpHost: ''
|
||||
smtpPort: 465 (default OVH)
|
||||
smtpEncryption: 'ssl' (default OVH)
|
||||
username: ''
|
||||
password: '' (write-only — jamais pré-rempli)
|
||||
sentFolderPath: '' (ex: "Sent Messages" ou "INBOX.Sent")
|
||||
enabled: false
|
||||
```
|
||||
- [ ] `hasPassword` : `ref<boolean>(false)` — alimenté par `getConfiguration().hasPassword`
|
||||
- [ ] `isSaving` : `ref<boolean>(false)`, `isTesting` : `ref<boolean>(false)`
|
||||
- [ ] `testResult` : `ref<boolean | null>(null)` — réinitialisé à null au handleSave
|
||||
- [ ] `loadSettings()` :
|
||||
```ts
|
||||
async function loadSettings(): Promise<void> {
|
||||
const config = await getConfiguration()
|
||||
form.protocol = config.protocol ?? 'imap'
|
||||
form.imapHost = config.imapHost ?? ''
|
||||
form.imapPort = config.imapPort ?? 993
|
||||
form.imapEncryption = config.imapEncryption ?? 'ssl'
|
||||
form.smtpHost = config.smtpHost ?? ''
|
||||
form.smtpPort = config.smtpPort ?? 465
|
||||
form.smtpEncryption = config.smtpEncryption ?? 'ssl'
|
||||
form.username = config.username ?? ''
|
||||
form.sentFolderPath = config.sentFolderPath ?? ''
|
||||
form.enabled = config.enabled
|
||||
hasPassword.value = config.hasPassword
|
||||
// password jamais pré-rempli
|
||||
}
|
||||
```
|
||||
- [ ] `handleSave()` : construit un `MailConfigurationUpdateDto` — inclure `password` uniquement si `form.password` est non-vide, sinon omettre le champ. Après save réussi : `hasPassword.value = result.hasPassword`, vider `form.password`, `testResult.value = null`
|
||||
- [ ] `handleTest()` : appelle `testConfiguration()`, `testResult.value = result.ok`. Le champ `result.error` est affiché en sous-texte si `testResult.value === false`
|
||||
- [ ] Template — sections IMAP et SMTP avec labels traduits :
|
||||
- Titre `h2` : `$t('mail.admin.title')`
|
||||
- Section IMAP (`fieldset` ou `div` avec titre `$t('mail.admin.imapSection')`) :
|
||||
- `MalioInputText` pour `imapHost` + helper text `$t('mail.admin.ovhDefaultsHelp')` sous le champ (texte gris : `ssl0.ovh.net`)
|
||||
- `input[type=number]` natif pour `imapPort` (MalioInputText n'accepte pas les number — voir convention CLAUDE.md)
|
||||
- `select` natif pour `imapEncryption` (options : `ssl`, `tls`, `none`)
|
||||
- Section SMTP (`$t('mail.admin.smtpSection')`) :
|
||||
- `MalioInputText` pour `smtpHost`
|
||||
- `input[type=number]` natif pour `smtpPort`
|
||||
- `select` natif pour `smtpEncryption` (options : `ssl`, `tls`, `none`)
|
||||
- Credentials :
|
||||
- `MalioInputText` pour `username`
|
||||
- `MalioInputPassword` pour `password` + indicateur `hasPassword` (même pattern que `AdminZimbraTab.vue` : `<p v-if="hasPassword && !form.password">{{ $t('mail.admin.passwordSet') }}</p>`)
|
||||
- `MalioInputText` pour `sentFolderPath` (placeholder: `Sent Messages`)
|
||||
- `label` + checkbox natif pour `enabled` : `$t('mail.admin.enabled')`
|
||||
- Boutons côte à côte :
|
||||
- `MalioButton` submit `$t('mail.admin.save')` `:disabled="isSaving"` → `handleSave`
|
||||
- `MalioButton` variant tertiary `$t('mail.admin.test')` `:disabled="isTesting"` → `handleTest`
|
||||
- Résultat test : `<p v-if="testResult !== null">` coloré vert/rouge selon valeur — si false ET `testError`, afficher `testError` sous le résultat
|
||||
- [ ] `onMounted(() => { loadSettings() })`
|
||||
- [ ] Vérifier indentation 4 espaces, pas d'imports inutilisés, TypeScript strict
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Intégration `AdminMailTab` dans `pages/admin.vue`
|
||||
|
||||
**Fichier cible :** `frontend/pages/admin.vue`
|
||||
|
||||
Le pattern actuel utilise un tableau `tabs as const` + `activeTab` ref + v-if par composant. Il suffit d'ajouter l'entrée mail à la fin.
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] Ouvrir `frontend/pages/admin.vue`
|
||||
- [ ] Dans le tableau `tabs`, ajouter à la fin :
|
||||
```ts
|
||||
{ key: 'mail', label: 'Mail' },
|
||||
```
|
||||
Remarque : les labels dans `tabs` sont des string litéraux inline (cf. autres onglets comme `'Zimbra'`), pas de i18n ici.
|
||||
- [ ] Le type `TabKey` est inféré automatiquement via `typeof tabs[number]['key']` — pas de changement nécessaire
|
||||
- [ ] Dans le template, après `<AdminZimbraTab v-if="activeTab === 'zimbra'" />`, ajouter :
|
||||
```html
|
||||
<AdminMailTab v-if="activeTab === 'mail'" />
|
||||
```
|
||||
- [ ] Vérifier que Nuxt auto-importe `AdminMailTab` (fichier dans `components/admin/` → auto-import OK)
|
||||
- [ ] Test manuel : naviguer vers `/admin`, cliquer l'onglet "Mail", vérifier que le form se charge sans erreur 403 si connecté ROLE_ADMIN
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Lien sidebar dans `layouts/default.vue`
|
||||
|
||||
**Fichier cible :** `frontend/layouts/default.vue`
|
||||
|
||||
Le composant `SidebarLink` accepte `to`, `icon`, `label`, `collapsed`. Il n'a pas de prop `badge` native — vérifier dans `@malio/layer-ui/COMPONENTS.md` si une prop badge existe. Si non, wrapper manuel avec un `<div class="relative">` + badge absolu.
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] Lire `frontend/node_modules/@malio/layer-ui/COMPONENTS.md` pour vérifier les props de `SidebarLink` (présence prop `badge` ou `badgeCount`)
|
||||
- [ ] **Cas A — SidebarLink a une prop badge :**
|
||||
Utiliser directement :
|
||||
```html
|
||||
<SidebarLink
|
||||
v-if="isMailVisible"
|
||||
to="/mail"
|
||||
icon="material-symbols:mail-outline"
|
||||
label="$t('mail.sidebar.title')"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
:badge="mailStore.globalUnreadCount > 0 ? mailStore.globalUnreadCount : undefined"
|
||||
aria-label="$t('mail.sidebar.ariaLabel')"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
```
|
||||
- [ ] **Cas B — SidebarLink n'a pas de prop badge (plus probable) :**
|
||||
Wrapper avec badge manuel :
|
||||
```html
|
||||
<div v-if="isMailVisible" class="relative">
|
||||
<SidebarLink
|
||||
to="/mail"
|
||||
icon="material-symbols:mail-outline"
|
||||
:label="$t('mail.sidebar.title')"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<span
|
||||
v-if="mailStore.globalUnreadCount > 0"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||
>
|
||||
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
```
|
||||
- [ ] Dans `<script setup>`, ajouter :
|
||||
```ts
|
||||
const mailStore = useMailStore()
|
||||
```
|
||||
- [ ] Définir le computed `isMailVisible` :
|
||||
```ts
|
||||
const isMailVisible = computed(() => {
|
||||
const roles: string[] = auth.user?.roles ?? []
|
||||
// Visible si ROLE_USER (ou ROLE_ADMIN) mais pas ROLE_CLIENT exclusif
|
||||
const isClient = roles.includes('ROLE_CLIENT') && !roles.includes('ROLE_ADMIN') && !roles.includes('ROLE_USER')
|
||||
return !isClient && (roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN'))
|
||||
})
|
||||
```
|
||||
- [ ] Placer le lien sidebar **après** `SidebarLink to="/my-tasks"` et **avant** `SidebarLink to="/projects"` (ordre logique : dashboard → mes tâches → mail → projets → suivi de temps → admin)
|
||||
- [ ] Vérifier responsive : en mode collapsed (`sidebarIsCollapsed = true`), le badge doit rester visible et accessible
|
||||
- [ ] Test manuel : utilisateur ROLE_CLIENT seul → lien absent. Utilisateur ROLE_USER → lien visible. Badge rouge si `globalUnreadCount > 0`
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Lifecycle polling start/stop
|
||||
|
||||
**Fichier cible :** `frontend/layouts/default.vue`
|
||||
|
||||
Le store `useMailStore` expose `startPolling()` (idempotent — guard `if (pollTimer) return`) et `stopPolling()`. Le polling doit démarrer au montage du layout (si l'utilisateur est autorisé) et s'arrêter au logout.
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] Dans `onMounted` de `layouts/default.vue` (qui contient déjà `timerStore.fetchActive()`), ajouter après :
|
||||
```ts
|
||||
if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
}
|
||||
```
|
||||
- [ ] Vérifier que `isMailVisible` est disponible dans le même scope (oui, c'est un computed défini dans `<script setup>`)
|
||||
- [ ] Pour le stop au logout : dans `useAuthStore`, le logout vide l'user. Watcher sur `auth.user` dans le layout :
|
||||
```ts
|
||||
watch(() => auth.user, (user) => {
|
||||
if (!user) {
|
||||
mailStore.stopPolling()
|
||||
} else if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
}
|
||||
})
|
||||
```
|
||||
- [ ] Vérifier l'idempotence : `startPolling()` dans le store a déjà `if (pollTimer) return` — naviguer entre les pages ne crée pas plusieurs timers
|
||||
- [ ] `onUnmounted` dans le layout n'est pas nécessaire car le layout persiste toute la session ; le watch sur `auth.user` suffit
|
||||
- [ ] Test manuel : ouvrir devtools → Network → vérifier un seul appel `GET /api/mail/folders` toutes les 30s, pas de rafale
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : i18n additionnels Phase 7
|
||||
|
||||
**Fichiers cibles :** `frontend/i18n/locales/fr.json` (et `en.json` si présent)
|
||||
|
||||
### Clés à ajouter (section `mail` — fusionner avec les clés existantes des phases précédentes)
|
||||
|
||||
```json
|
||||
{
|
||||
"mail": {
|
||||
"sidebar": {
|
||||
"title": "Messagerie",
|
||||
"ariaLabel": "Accès à la messagerie, {count} messages non lus"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Configuration messagerie",
|
||||
"protocol": "Protocole",
|
||||
"imapSection": "Réception (IMAP)",
|
||||
"smtpSection": "Envoi (SMTP)",
|
||||
"host": "Serveur",
|
||||
"port": "Port",
|
||||
"encryption": "Chiffrement",
|
||||
"username": "Adresse e-mail",
|
||||
"password": "Mot de passe",
|
||||
"passwordSet": "Mot de passe déjà configuré — laisser vide pour conserver",
|
||||
"sentFolderPath": "Dossier des envois",
|
||||
"enabled": "Activer la synchronisation mail",
|
||||
"test": "Tester la connexion",
|
||||
"testSuccess": "Connexion IMAP réussie",
|
||||
"testFailed": "Échec de connexion",
|
||||
"save": "Enregistrer",
|
||||
"saveSuccess": "Configuration enregistrée",
|
||||
"ovhDefaultsHelp": "OVH : ssl0.ovh.net (port 993 IMAP / 465 SMTP)"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] Ouvrir `frontend/i18n/locales/fr.json`
|
||||
- [ ] Localiser la section `mail` existante (créée en Phase 4/5)
|
||||
- [ ] Fusionner les clés `mail.sidebar.*` et `mail.admin.*` sans écraser les clés existantes
|
||||
- [ ] Si `en.json` existe : ajouter les équivalents anglais (traduction directe — pas d'approximation)
|
||||
- [ ] Vérifier la cohérence JSON (virgules, pas de clés dupliquées)
|
||||
- [ ] `make dev-nuxt` → console browser → 0 warning `[vue-i18n] Missing locale message`
|
||||
|
||||
---
|
||||
|
||||
## Task 6 : Documentation finale
|
||||
|
||||
### 6a — Enrichir `docs/mail-cron-setup.md`
|
||||
|
||||
**Fichier cible :** `docs/mail-cron-setup.md`
|
||||
|
||||
Ce fichier existe déjà (créé Phase 2). Ajouter les sections manquantes :
|
||||
|
||||
- [ ] Ajouter section **"Checklist setup production"** après la section "Variables d'environnement" :
|
||||
```markdown
|
||||
## Checklist setup production
|
||||
|
||||
1. [ ] Définir `ENCRYPTION_KEY` dans les variables d'environnement production
|
||||
2. [ ] Créer le compte mail dédié (ex: `lesstime@votre-domaine.fr`) chez OVH
|
||||
3. [ ] Accéder à `/admin` → onglet "Mail" → renseigner les credentials IMAP/SMTP
|
||||
4. [ ] Cliquer "Tester la connexion" → vérifier le succès
|
||||
5. [ ] Cocher "Activer la synchronisation" → Enregistrer
|
||||
6. [ ] Installer le cron OS (voir section "Installation du cron")
|
||||
7. [ ] Vérifier les logs après la première sync : `make logs-dev` (chercher `mail.sync`)
|
||||
```
|
||||
- [ ] Ajouter section **"Sécurité"** (si absente ou incomplète) :
|
||||
```markdown
|
||||
## Rappels sécurité
|
||||
|
||||
- La page `/mail` et tous les endpoints `/api/mail/*` sont refusés aux `ROLE_CLIENT` exclusifs
|
||||
- Le sidebar "Messagerie" est masqué pour les utilisateurs ROLE_CLIENT sans ROLE_USER
|
||||
- Le password IMAP est chiffré via libsodium secretbox avant stockage (jamais en clair en base)
|
||||
- Les corps de mails sont sanitisés via DOMPurify avant affichage (voir `frontend/utils/sanitizeMailHtml.ts`)
|
||||
- Les pixels tracking distants sont remplacés par un placeholder
|
||||
- Aucun body mail, password ou contenu de pièce jointe n'est loggé
|
||||
```
|
||||
|
||||
### 6b — Créer `docs/mail-integration.md`
|
||||
|
||||
**Fichier cible :** `docs/mail-integration.md`
|
||||
|
||||
- [ ] Créer le fichier avec les sections suivantes :
|
||||
|
||||
```markdown
|
||||
# Intégration Mail — Vue d'ensemble
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- Lecture de la boîte mail partagée (IMAP) depuis Lesstime
|
||||
- Navigation par dossiers (arbre récursif avec compteurs non-lus)
|
||||
- Liste paginée des messages (infinite scroll, cursor-based)
|
||||
- Lecture des corps de mail sanitisés (DOMPurify — protection XSS + pixels tracking)
|
||||
- Création d'une tâche Lesstime depuis un mail (sujet → titre, texte → description)
|
||||
- Lien mail ↔ tâche (bidirectionnel)
|
||||
- Onglet "Mails" dans le TaskDrawer pour retrouver les mails liés à une tâche
|
||||
- Synchronisation IMAP automatique via cron OS (toutes les 10 min)
|
||||
- Déclenchement manuel de sync depuis l'UI (bouton Refresh)
|
||||
- Badge non-lus en temps réel dans la sidebar (polling 30s)
|
||||
|
||||
## Endpoints API
|
||||
|
||||
| Méthode | URL | Rôle | Description |
|
||||
|---------|-----|------|-------------|
|
||||
| GET | `/api/mail/configuration` | ROLE_ADMIN | Lire la config singleton |
|
||||
| PATCH | `/api/mail/configuration` | ROLE_ADMIN | Mettre à jour la config |
|
||||
| POST | `/api/mail/configuration/test` | ROLE_ADMIN | Tester la connexion IMAP |
|
||||
| GET | `/api/mail/folders` | ROLE_USER | Arbre des dossiers + unread |
|
||||
| GET | `/api/mail/messages` | ROLE_USER | Liste paginée (param: folder, cursor, limit) |
|
||||
| GET | `/api/mail/messages/{id}` | ROLE_USER | Détail + body (cached 5 min) |
|
||||
| POST | `/api/mail/messages/{id}/read` | ROLE_USER | Marquer lu/non-lu |
|
||||
| POST | `/api/mail/messages/{id}/flag` | ROLE_USER | Marquer étoilé/non-étoilé |
|
||||
| POST | `/api/mail/messages/{id}/create-task` | ROLE_USER | Créer tâche depuis mail |
|
||||
| POST | `/api/mail/messages/{id}/link-task` | ROLE_USER | Lier mail à tâche existante |
|
||||
| DELETE | `/api/mail/messages/{id}/link-task/{taskId}` | ROLE_USER | Supprimer le lien |
|
||||
| GET | `/api/tasks/{id}/mails` | ROLE_USER | Mails liés à une tâche |
|
||||
| GET | `/api/mail/attachments/{id}` | ROLE_USER | Télécharger une pièce jointe |
|
||||
| POST | `/api/mail/sync` | ROLE_USER | Déclencher sync async (Messenger) |
|
||||
|
||||
Tous les endpoints `/api/mail/*` refusent explicitement `ROLE_CLIENT`.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- ROLE_CLIENT exclusif : accès refusé à tous les endpoints mail et à la page `/mail`
|
||||
- Le sidebar "Messagerie" est masqué pour les ROLE_CLIENT
|
||||
- Password IMAP chiffré via libsodium secretbox (env `ENCRYPTION_KEY`)
|
||||
- Corps de mail sanitisés via DOMPurify (`sanitizeMailHtml.ts`) — script/iframe/object/embed/on*/javascript: bloqués
|
||||
- Pixels tracking distants (img src http) remplacés par placeholder
|
||||
- Aucun body, password ou contenu de pièce jointe dans les logs
|
||||
|
||||
## Dépendances
|
||||
|
||||
### Backend
|
||||
- `webklex/php-imap` : client IMAP PHP
|
||||
- `symfony/lock` : Symfony Lock pour éviter les syncs parallèles
|
||||
- `symfony/messenger` : dispatch asynchrone `MailSyncRequested`
|
||||
- `libsodium` (ext PHP) : chiffrement du password IMAP
|
||||
|
||||
### Frontend
|
||||
- `dompurify` + `@types/dompurify` : sanitization HTML des corps de mail
|
||||
|
||||
## Fichiers clés
|
||||
|
||||
### Backend
|
||||
- `src/Entity/MailConfiguration.php` — entité singleton (credentials, enabled)
|
||||
- `src/Entity/MailFolder.php` — dossier IMAP synced
|
||||
- `src/Entity/MailMessage.php` — message IMAP synced (headers, flags)
|
||||
- `src/Entity/TaskMailLink.php` — lien tâche ↔ mail
|
||||
- `src/Mail/ImapMailProvider.php` — implémentation IMAP (webklex)
|
||||
- `src/Service/MailSyncService.php` — algorithme de sync (UID FETCH, resync flags)
|
||||
- `src/Controller/Mail/` — controllers custom (test, folders, messages, sync)
|
||||
- `src/State/Mail/` — providers/processors API Platform (configuration)
|
||||
|
||||
### Frontend
|
||||
- `frontend/pages/mail.vue` — page principale 3 colonnes
|
||||
- `frontend/components/mail/` — MailFolderTree, MailMessageList, MailMessageViewer, MailRefreshButton
|
||||
- `frontend/components/admin/AdminMailTab.vue` — onglet config admin
|
||||
- `frontend/stores/mail.ts` — store Pinia (folders, messages, polling)
|
||||
- `frontend/services/mail.ts` — service API (toutes les méthodes)
|
||||
- `frontend/services/dto/mail.ts` — types TypeScript
|
||||
- `frontend/utils/sanitizeMailHtml.ts` — DOMPurify wrapper
|
||||
|
||||
## Synchronisation cron
|
||||
|
||||
Voir `docs/mail-cron-setup.md` pour la configuration détaillée.
|
||||
|
||||
Résumé :
|
||||
```bash
|
||||
# Cron OS (toutes les 10 min)
|
||||
*/10 * * * * cd /path/to/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1
|
||||
|
||||
# Commandes Makefile
|
||||
make mail-sync # Sync complète
|
||||
make mail-sync FOLDER=INBOX # Sync d'un dossier
|
||||
make mail-sync DRYRUN=1 # Simulation sans écriture
|
||||
```
|
||||
|
||||
## Configuration admin
|
||||
|
||||
1. Aller sur `/admin` → onglet "Mail"
|
||||
2. Renseigner les credentials IMAP/SMTP (OVH : `ssl0.ovh.net`, port 993/465, SSL)
|
||||
3. Cliquer "Tester la connexion"
|
||||
4. Activer la synchronisation → Enregistrer
|
||||
5. Configurer le cron OS
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
| Variable | Description | Obligatoire |
|
||||
|----------|-------------|-------------|
|
||||
| `ENCRYPTION_KEY` | Clé hex 32 bytes libsodium pour chiffrer le password IMAP | Oui |
|
||||
| `LOCK_DSN` | DSN Symfony Lock (défaut: `flock`) | Non |
|
||||
| `MESSENGER_TRANSPORT_DSN` | Transport Messenger pour sync async | Recommandé (prod) |
|
||||
```
|
||||
|
||||
### 6c — Vérifier `make mail-sync` dans le README
|
||||
|
||||
- [ ] Ouvrir `README.md` à la racine de Lesstime
|
||||
- [ ] Vérifier si une section mail ou une mention de `make mail-sync` existe déjà
|
||||
- [ ] Si absente : ajouter dans la section des commandes Makefile une ligne documentant `make mail-sync` avec la description courte (cf. le commentaire déjà présent dans le makefile)
|
||||
|
||||
---
|
||||
|
||||
## Task 7 : Vérifications sécurité finales
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] Ouvrir `frontend/utils/sanitizeMailHtml.ts` — vérifier la config DOMPurify :
|
||||
- `FORBID_TAGS` doit inclure : `script`, `iframe`, `object`, `embed`, `form`, `input`
|
||||
- `FORBID_ATTR` doit inclure tous les handlers `on*` + `javascript:` dans `href`/`src`
|
||||
- Les `<img src="http(s)://...">` distants sont remplacés par un placeholder (pas juste supprimés)
|
||||
- Si manquant, noter la correction mais ne pas modifier (la correction est documentée ici pour le codeur)
|
||||
- [ ] Test injection XSS manuel (dans la console browser, sur la page `/mail`) :
|
||||
```js
|
||||
import('/utils/sanitizeMailHtml').then(m => {
|
||||
console.log(m.sanitizeMailHtml('<script>alert(1)</script><img src=x onerror=alert(2)><iframe src="javascript:alert(3)"></iframe>'))
|
||||
})
|
||||
```
|
||||
Résultat attendu : chaîne sans `<script>`, sans `onerror`, sans `<iframe>`
|
||||
- [ ] Grep logs — confirmer aucun body/password/attachment dans les logs :
|
||||
```bash
|
||||
grep -rn "bodyHtml\|bodyText\|password\|attachment.*content" src/Mail/ src/Service/MailSyncService.php src/Controller/Mail/ --include="*.php"
|
||||
```
|
||||
Vérifier que les occurrences trouvées sont uniquement des définitions de propriétés, jamais passées à un logger
|
||||
- [ ] Vérifier que `GET /api/mail/configuration` ne retourne jamais de champ `password` dans la réponse JSON (tester avec `curl -s http://localhost:8082/api/mail/configuration -H "Cookie: BEARER=..."` ou équivalent)
|
||||
- [ ] Vérifier que `POST /api/mail/folders` avec un cookie ROLE_CLIENT retourne bien 403
|
||||
|
||||
---
|
||||
|
||||
## Task 8 : QA passe end-to-end
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] `make test` → 0 failure, 0 error
|
||||
- [ ] `make php-cs-fixer-allow-risky` → idempotent (0 fichier modifié)
|
||||
- [ ] `cd frontend && npx tsc --noEmit` → 0 erreur TypeScript
|
||||
- [ ] `make dev-nuxt` → démarrage OK, 0 erreur console browser au load de `/mail`
|
||||
- [ ] **Workflow admin :**
|
||||
- Se connecter en admin
|
||||
- Aller sur `/admin` → onglet "Mail"
|
||||
- Renseigner `imapHost = ssl0.ovh.net`, `imapPort = 993`, `imapEncryption = ssl`, `username = test@example.com`, `password = test`
|
||||
- Cliquer "Tester la connexion" → résultat affiché (succès ou échec selon config réelle)
|
||||
- Enregistrer → toast "Configuration enregistrée"
|
||||
- Rechargement de la page → les champs sont pré-remplis, indicateur "Mot de passe déjà configuré" visible
|
||||
- [ ] **Workflow sidebar :**
|
||||
- Se connecter en ROLE_USER
|
||||
- Vérifier que le lien "Messagerie" est visible dans la sidebar
|
||||
- Vérifier le badge si `globalUnreadCount > 0`
|
||||
- Se connecter en ROLE_CLIENT → vérifier l'absence du lien sidebar
|
||||
- [ ] **Workflow polling :**
|
||||
- Ouvrir les DevTools → Network → filtrer sur `mail/folders`
|
||||
- Rester sur une page 90s → exactement 3 appels (1 immédiat + 2 toutes les 30s)
|
||||
- Naviguer entre `/mail` et `/my-tasks` → pas de rafale, pas de duplication du polling
|
||||
- [ ] **Workflow complet mail → tâche (régression Phase 6) :**
|
||||
- Ouvrir un mail dans `/mail`
|
||||
- Cliquer "Créer tâche" → modal → sélectionner projet → créer
|
||||
- Tâche apparaît dans `/my-tasks` avec le mail lié
|
||||
- Depuis le TaskDrawer de la tâche → onglet "Mails" → mail visible → cliquer → redirection `/mail?messageId=X`
|
||||
- [ ] **Simulation sync :**
|
||||
- `make mail-sync DRYRUN=1` → commande retourne 0, pas d'erreur Symfony
|
||||
|
||||
---
|
||||
|
||||
## Task 9 : Cleanup final
|
||||
|
||||
### Étapes
|
||||
|
||||
- [ ] Grep debug dans tous les fichiers mail frontend :
|
||||
```bash
|
||||
grep -rn "console\.log\|console\.warn\|console\.error\|debugger" frontend/components/mail/ frontend/components/admin/AdminMailTab.vue frontend/stores/mail.ts frontend/services/mail.ts frontend/utils/sanitizeMailHtml.ts
|
||||
```
|
||||
Supprimer toute occurrence (sauf `console.error` intentionnel avec commentaire explicatif)
|
||||
- [ ] Grep TODO/FIXME/HACK :
|
||||
```bash
|
||||
grep -rn "TODO\|FIXME\|HACK\|XXX" frontend/components/mail/ frontend/components/admin/AdminMailTab.vue frontend/stores/mail.ts frontend/services/mail.ts
|
||||
```
|
||||
Résoudre ou supprimer chaque occurrence
|
||||
- [ ] Vérifier qu'aucun import inutilisé ne traîne dans `AdminMailTab.vue` et les fichiers modifiés dans `layouts/default.vue`
|
||||
- [ ] `cd frontend && npx tsc --noEmit` → toujours 0 erreur après cleanup
|
||||
- [ ] Si des modifications ont été faites depuis le dernier commit Phase 6, créer un commit final :
|
||||
```
|
||||
feat(mail) : Phase 7 — admin config tab, sidebar badge, polling lifecycle
|
||||
docs(mail) : documentation intégration mail complète
|
||||
```
|
||||
(deux commits séparés si les changements sont distincts)
|
||||
|
||||
---
|
||||
|
||||
## Critères d'acceptation (Phase 7 complète)
|
||||
|
||||
- [ ] Admin peut accéder à `/admin` → onglet "Mail" → configurer IMAP/SMTP → tester → activer
|
||||
- [ ] Le sidebar affiche un badge unread actualisé toutes les 30s pour ROLE_USER/ROLE_ADMIN
|
||||
- [ ] Le sidebar "Messagerie" est invisible pour ROLE_CLIENT exclusif
|
||||
- [ ] `make test` vert
|
||||
- [ ] `npx tsc --noEmit` 0 erreur
|
||||
- [ ] 0 warning console browser au chargement
|
||||
- [ ] 0 ERROR PHP dans `make logs-dev` pendant le workflow normal
|
||||
- [ ] `docs/mail-integration.md` complet et accessible
|
||||
- [ ] `docs/mail-cron-setup.md` enrichi avec checklist prod et rappels sécurité
|
||||
|
||||
---
|
||||
|
||||
## Dépendances
|
||||
|
||||
- **Phase 5** (store `useMailStore` avec `startPolling`/`stopPolling` + page `/mail`) — DONE
|
||||
- **Phase 6** (intégration tâches) — DONE
|
||||
- **Phase 3** (endpoints `/api/mail/configuration` GET/PATCH/test, ROLE_CLIENT refusé) — DONE
|
||||
- **Phase 4** (services `getConfiguration`, `updateConfiguration`, `testConfiguration`, DTOs) — DONE
|
||||
@@ -1,988 +0,0 @@
|
||||
# Correctifs UI workflow — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommandé) ou superpowers:executing-plans pour exécuter ce plan tâche par tâche. Les étapes utilisent la syntaxe checkbox (`- [ ]`).
|
||||
|
||||
**Goal:** Corriger les régressions UI introduites par les workflows (D&D, sélecteur de statut, cartes, couleurs) et améliorer l'UX mail/modales, sur la base de `docs/superpowers/specs/2026-05-20-workflow-ui-fixes-design.md`.
|
||||
|
||||
**Architecture:** Une brique partagée (filtrage des statuts par workflow + palette de catégories + composant modale réutilisable) consommée par les autres chantiers. Backend modifié uniquement pour l'endpoint `create-task` (#6). Correction de données prod (#4) via migration Doctrine.
|
||||
|
||||
**Tech Stack:** Symfony 8 / API Platform 4 / Doctrine (backend, PHPUnit) ; Nuxt 4 / Vue 3 / Pinia / Tailwind / `@malio/layer-ui` (frontend).
|
||||
|
||||
> **Note testing (importante).** Lesstime **n'a pas de test runner frontend** (vérifié : pas de vitest/jest dans `frontend/package.json`). La discipline TDD ne s'applique donc qu'au **backend** (PHPUnit via `make test`). Pour le **frontend**, chaque tâche se vérifie par : (1) `npm run build:dist` qui doit réussir (exit 0), puis (2) contrôle navigateur via Chrome DevTools MCP sur `http://localhost:8082` (DOM/visuel). **Toujours hard-reload sans cache** après build (le navigateur cache les chunks JS hashés). Login dev avec données prod importées : `Matthieu` / `admin`.
|
||||
|
||||
> **Branche.** Créer une branche d'implémentation depuis `develop` (ex. `fix/workflow-ui-fixes`) avant de commencer. Commits fréquents, format `<type>(<scope>) : <message>`.
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'exécution (dépendances)
|
||||
|
||||
1. **Task 1** — Brique front : palette de catégories + helper contraste (`#4b`, réutilisé par #1)
|
||||
2. **Task 2** — Composant `AppModal` réutilisable (`#7`)
|
||||
3. **Task 3** — Filtrage du sélecteur de statut par workflow dans TaskModal (`#2`)
|
||||
4. **Task 4** — Drag & drop dans « Mes tâches » + entêtes teintées (`#1` + `#4b`)
|
||||
5. **Task 5** — Backend : endpoint `create-task` (statut + assigné, sans priorité) (`#6` back)
|
||||
6. **Task 6** — Frontend : modale de création depuis mail (`#6` front, sur AppModal)
|
||||
7. **Task 7** — Suppression du bouton « Lier un mail » (`#5`)
|
||||
8. **Task 8** — Cartes responsive (`#3`)
|
||||
9. **Task 9** — Couleurs par défaut par catégorie + migration data prod (`#4a` + `#4c`)
|
||||
10. **Task 10** — Migration de TaskModal vers AppModal (`#7`)
|
||||
|
||||
---
|
||||
|
||||
## Task 1 : Palette de catégories + helper de contraste
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/services/dto/workflow.ts`
|
||||
|
||||
- [ ] **Step 1 : Ajouter la palette et le helper de contraste**
|
||||
|
||||
Dans `frontend/services/dto/workflow.ts`, après `STATUS_CATEGORY_LABEL` (l.5-11), ajouter :
|
||||
|
||||
```ts
|
||||
/** Palette canonique des catégories (couleurs « classiques »), indépendante des workflows. */
|
||||
export const STATUS_CATEGORY_COLOR: Record<StatusCategory, string> = {
|
||||
todo: '#222783',
|
||||
in_progress: '#4A90D9',
|
||||
blocked: '#C62828',
|
||||
review: '#FF8F00',
|
||||
done: '#26A69A',
|
||||
}
|
||||
|
||||
/** Renvoie '#1f2937' (foncé) ou '#ffffff' (blanc) selon la luminance du fond, pour rester lisible. */
|
||||
export function contrastText(hex: string): string {
|
||||
const c = hex.replace('#', '')
|
||||
const r = parseInt(c.slice(0, 2), 16)
|
||||
const g = parseInt(c.slice(2, 4), 16)
|
||||
const b = parseInt(c.slice(4, 6), 16)
|
||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return lum > 0.6 ? '#1f2937' : '#ffffff'
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Vérifier le build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected: exit 0, aucune erreur TypeScript.
|
||||
|
||||
- [ ] **Step 3 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/services/dto/workflow.ts
|
||||
git commit -m "feat(workflow) : palette de catégories canonique + helper de contraste"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 : Composant modale réutilisable `AppModal` (#7)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/ui/AppModal.vue`
|
||||
|
||||
- [ ] **Step 1 : Créer `AppModal.vue`**
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
/** Largeur max du panneau */
|
||||
width?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}>(), {
|
||||
title: '',
|
||||
width: 'md',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const WIDTH_CLASS: Record<NonNullable<typeof props.width>, string> = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="app-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="close" />
|
||||
|
||||
<div
|
||||
class="relative z-10 flex max-h-[90vh] w-full flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||
:class="WIDTH_CLASS[width]"
|
||||
>
|
||||
<!-- Header (fixe) -->
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
||||
<h2 class="text-base font-bold text-neutral-900">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</h2>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Body (scrollable) -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer (sticky) -->
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="flex shrink-0 justify-end gap-3 border-t border-neutral-100 bg-white px-6 py-4"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-modal-enter-active,
|
||||
.app-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.app-modal-enter-active > div:last-child,
|
||||
.app-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
.app-modal-enter-from,
|
||||
.app-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.app-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 3 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/ui/AppModal.vue
|
||||
git commit -m "feat(ui) : composant AppModal réutilisable (header fixe / body scrollable / footer sticky)"
|
||||
```
|
||||
|
||||
> AppModal sera consommé par MailCreateTaskModal (Task 6) et TaskModal (Task 10).
|
||||
|
||||
---
|
||||
|
||||
## Task 3 : Filtrer le sélecteur de statut par workflow dans TaskModal (#2)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/task/TaskModal.vue` (statusOptions ~l.674-676)
|
||||
|
||||
**Contexte vérifié :** TaskModal reçoit déjà `:projects` (`Project[]` avec `workflow.statuses`). Le projet effectif est `showProjectSelect ? form.projectId : props.projectId` (cf. l.717). `props.statuses` (global) devient un fallback.
|
||||
|
||||
- [ ] **Step 1 : Remplacer `statusOptions`**
|
||||
|
||||
Remplacer (l.674-676) :
|
||||
|
||||
```ts
|
||||
const statusOptions = computed(() =>
|
||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
```
|
||||
|
||||
par :
|
||||
|
||||
```ts
|
||||
const effectiveProjectId = computed(() =>
|
||||
showProjectSelect.value ? form.projectId : props.projectId,
|
||||
)
|
||||
|
||||
const statusOptions = computed(() => {
|
||||
const project = props.projects?.find(p => p.id === effectiveProjectId.value)
|
||||
const wfStatuses = project?.workflow?.statuses ?? props.statuses
|
||||
const opts = wfStatuses.map(s => ({ label: s.label, value: s.id }))
|
||||
// Garder le statut courant s'il n'appartient pas (plus) au workflow, pour ne pas le perdre.
|
||||
const current = props.task?.status
|
||||
if (current && !wfStatuses.some(s => s.id === current.id)) {
|
||||
opts.unshift({ label: current.label, value: current.id })
|
||||
}
|
||||
return opts
|
||||
})
|
||||
```
|
||||
|
||||
> Si une variable `effectiveProjectId`/`activeProjectId` existe déjà (vérifier autour de l.717), réutiliser celle-ci au lieu d'en redéclarer une.
|
||||
|
||||
- [ ] **Step 2 : Build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 3 : Vérification navigateur (Chrome MCP)**
|
||||
|
||||
1. Hard-reload `http://localhost:8082` (cache ignoré), login `Matthieu`/`admin`.
|
||||
2. Ouvrir une tâche d'un projet **Standard** (ex. `LST-49` via « Mes tâches »).
|
||||
3. Ouvrir le sélecteur « Statut ».
|
||||
Expected : **5 options** (les statuts du workflow Standard) — plus aucun statut ERP (« Prêt à dev », « En dev », « Mergé », « Validation client », « Validé prod », « Abandonné »).
|
||||
4. Ouvrir une tâche du projet **STARSEED** (workflow ERP, code `ERP-…`).
|
||||
Expected : uniquement les statuts ERP.
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/task/TaskModal.vue
|
||||
git commit -m "fix(task) : sélecteur de statut filtré par le workflow du projet"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 : Drag & drop « Mes tâches » + entêtes teintées (#1 + #4b)
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/task/StatusPickerPopover.vue`
|
||||
- Modify: `frontend/pages/my-tasks.vue` (template kanban ~l.394-424 ; script ~l.118-140)
|
||||
- Modify: `frontend/services/tasks.ts` (réutiliser `update()` existant)
|
||||
|
||||
**Contexte vérifié :** `TaskCard.vue` pose déjà `dataTransfer.setData('text/plain', task.id)` au `dragstart`. `my-tasks.vue` n'a **aucun** handler `@drop`/`@dragover`. Les colonnes itèrent sur `CATEGORIES` (l.119). `tasks.value` contient les tâches affichées. `tasks.ts` expose `update(id, payload: Partial<TaskWrite>)` → `PATCH /tasks/{id}` ; le statut s'écrit en IRI (`status: '/api/task_statuses/{id}'`, cf. TaskModal l.1070).
|
||||
|
||||
- [ ] **Step 1 : Créer le popover de désambiguïsation**
|
||||
|
||||
`frontend/components/task/StatusPickerPopover.vue` :
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
|
||||
defineProps<{
|
||||
statuses: TaskStatus[]
|
||||
x: number
|
||||
y: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
pick: [status: TaskStatus]
|
||||
cancel: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 z-[60]" @click="emit('cancel')" />
|
||||
<div
|
||||
class="fixed z-[61] min-w-44 rounded-lg border border-neutral-200 bg-white py-1 shadow-xl"
|
||||
:style="{ left: x + 'px', top: y + 'px' }"
|
||||
>
|
||||
<button
|
||||
v-for="s in statuses"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
|
||||
@click="emit('pick', s)"
|
||||
>
|
||||
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: s.color }" />
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Ajouter la logique de drop dans `my-tasks.vue` (script)**
|
||||
|
||||
Dans `<script setup>`, ajouter les imports et l'état (près des helpers kanban, l.118+) :
|
||||
|
||||
```ts
|
||||
import { STATUS_CATEGORY_COLOR, contrastText } from '~/services/dto/workflow'
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
|
||||
const dragOverCategory = ref<StatusCategory | null>(null)
|
||||
const pendingPicker = ref<{ statuses: TaskStatus[], task: Task, x: number, y: number } | null>(null)
|
||||
|
||||
function statusesForTaskCategory(task: Task, category: StatusCategory): TaskStatus[] {
|
||||
const wf = task.project?.workflow
|
||||
if (!wf) return []
|
||||
return wf.statuses.filter(s => s.category === category)
|
||||
}
|
||||
|
||||
async function applyStatus(task: Task, status: TaskStatus): Promise<void> {
|
||||
await taskService.update(task.id, { status: `/api/task_statuses/${status.id}` })
|
||||
await loadTasks() // recharge la liste (utiliser la fonction de rechargement existante)
|
||||
}
|
||||
|
||||
function onDrop(category: StatusCategory, event: DragEvent): void {
|
||||
dragOverCategory.value = null
|
||||
const taskId = Number(event.dataTransfer?.getData('text/plain'))
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (!task) return
|
||||
|
||||
const candidates = statusesForTaskCategory(task, category)
|
||||
if (candidates.length === 0) {
|
||||
toast.error(t('myTasks.dropRefused')) // 0 statut dans cette catégorie pour ce workflow
|
||||
return
|
||||
}
|
||||
if (candidates.length === 1) {
|
||||
void applyStatus(task, candidates[0])
|
||||
return
|
||||
}
|
||||
// ≥2 : popover de choix ancré au point de drop
|
||||
pendingPicker.value = { statuses: candidates, task, x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
function onPickerChoice(status: TaskStatus): void {
|
||||
if (pendingPicker.value) void applyStatus(pendingPicker.value.task, status)
|
||||
pendingPicker.value = null
|
||||
}
|
||||
```
|
||||
|
||||
> Adapter `loadTasks()` / `toast` / `t` aux noms réels du fichier (vérifier la fonction de rechargement des tâches et l'import du toast déjà utilisés dans `my-tasks.vue`).
|
||||
|
||||
- [ ] **Step 3 : Brancher le template kanban (#1) + entêtes teintées (#4b)**
|
||||
|
||||
Remplacer le bloc colonne (l.397-404) par :
|
||||
|
||||
```vue
|
||||
<div
|
||||
v-for="cat in CATEGORIES"
|
||||
:key="cat"
|
||||
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50 transition"
|
||||
:class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
|
||||
@dragover.prevent="dragOverCategory = cat"
|
||||
@dragleave="dragOverCategory = null"
|
||||
@drop="onDrop(cat, $event)"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold"
|
||||
:style="{ backgroundColor: STATUS_CATEGORY_COLOR[cat], color: contrastText(STATUS_CATEGORY_COLOR[cat]) }"
|
||||
>
|
||||
{{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
|
||||
</div>
|
||||
```
|
||||
|
||||
Puis, juste avant la fermeture du `<template>` (à côté de la TaskModal), ajouter le popover :
|
||||
|
||||
```vue
|
||||
<StatusPickerPopover
|
||||
v-if="pendingPicker"
|
||||
:statuses="pendingPicker.statuses"
|
||||
:x="pendingPicker.x"
|
||||
:y="pendingPicker.y"
|
||||
@pick="onPickerChoice"
|
||||
@cancel="pendingPicker = null"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Ajouter la clé i18n `myTasks.dropRefused`**
|
||||
|
||||
Dans `frontend/i18n/locales/fr.json` (et les autres locales présentes), sous `myTasks` : `"dropRefused": "Aucun statut de cette colonne dans le workflow de ce projet"`.
|
||||
|
||||
- [ ] **Step 5 : Build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 6 : Vérification navigateur (Chrome MCP)**
|
||||
|
||||
1. Hard-reload, login, aller à « Mes tâches » (vue kanban).
|
||||
Expected : entêtes de colonnes **colorées** (todo indigo, in_progress bleu, blocked rouge, review ambre **texte foncé**, done sarcelle).
|
||||
2. Glisser une carte d'un projet **Standard** de « À faire » vers « En cours ».
|
||||
Expected : le statut passe à « En cours » (1 seul statut in_progress → direct), la carte se déplace.
|
||||
3. Glisser une carte du projet **STARSEED** (workflow ERP) vers « En validation » (la catégorie `review` a ≥2 statuts ERP : En review, Mergé, Validation client).
|
||||
Expected : **popover** au point de drop listant ces statuts ; le choix applique le statut.
|
||||
|
||||
- [ ] **Step 7 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/task/StatusPickerPopover.vue frontend/pages/my-tasks.vue frontend/i18n/locales/
|
||||
git commit -m "fix(my-tasks) : drag & drop par workflow (popover si ambigu) + entêtes de colonnes teintées"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 : Backend `create-task` — statut + assigné, sans priorité (#6 back)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Controller/Mail/MailCreateTaskController.php`
|
||||
- Test: `tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php`
|
||||
|
||||
**Contexte vérifié :** `Task::setStatus(?TaskStatus)`, `Task::setAssignee(?User)` existent. `Project::getWorkflow()` ; `Workflow::getStatuses()` est ordonné `position ASC`. Accès mail = ROLE_USER/ROLE_ADMIN (cf. `MailAccessChecker`).
|
||||
|
||||
- [ ] **Step 1 : Écrire le test fonctionnel (TDD) — assigné + statut, priorité ignorée**
|
||||
|
||||
Ajouter dans `MailTaskIntegrationControllerTest.php` (crée ses prérequis via l'EntityManager) :
|
||||
|
||||
```php
|
||||
public function testCreateTaskAppliesStatusAndAssigneeAndIgnoresPriority(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
$container = static::getContainer();
|
||||
$em = $container->get('doctrine.orm.entity_manager');
|
||||
|
||||
$admin = $em->getRepository(\App\Entity\User::class)->findOneBy(['username' => 'admin']);
|
||||
$client->loginUser($admin);
|
||||
|
||||
// Projet existant (fixtures) + son workflow / premier statut + un message mail existant
|
||||
$project = $em->getRepository(\App\Entity\Project::class)->findOneBy([]);
|
||||
self::assertNotNull($project, 'Au moins un projet doit exister dans les fixtures');
|
||||
$status = $project->getWorkflow()->getStatuses()->first();
|
||||
$message = $em->getRepository(\App\Entity\MailMessage::class)->findOneBy([]);
|
||||
self::assertNotNull($message, 'Au moins un message mail doit exister (fixtures ou sync)');
|
||||
|
||||
$client->request(
|
||||
'POST',
|
||||
'/api/mail/messages/'.$message->getId().'/create-task',
|
||||
[], [], ['CONTENT_TYPE' => 'application/json'],
|
||||
json_encode([
|
||||
'projectId' => $project->getId(),
|
||||
'assigneeId' => $admin->getId(),
|
||||
'statusId' => $status->getId(),
|
||||
'priorityId' => 999, // doit être ignoré
|
||||
])
|
||||
);
|
||||
|
||||
self::assertResponseStatusCodeSame(201);
|
||||
$payload = json_decode($client->getResponse()->getContent(), true);
|
||||
|
||||
$task = $em->getRepository(\App\Entity\Task::class)->find($payload['taskId']);
|
||||
self::assertSame($status->getId(), $task->getStatus()?->getId());
|
||||
self::assertSame($admin->getId(), $task->getAssignee()?->getId());
|
||||
self::assertNull($task->getPriority(), 'priorityId ne doit plus être pris en compte');
|
||||
}
|
||||
```
|
||||
|
||||
> Si les fixtures ne contiennent pas de `MailMessage`, créer dans le test un `MailConfiguration` + `MailFolder` + `MailMessage` minimal via l'EM (adapter aux champs requis des entités), ou charger un dump mail. Le test échoue tant que le contrôleur n'est pas modifié.
|
||||
|
||||
- [ ] **Step 2 : Lancer le test (doit échouer)**
|
||||
|
||||
Run: `make test` (ou `docker exec php-lesstime-fpm php bin/phpunit --filter testCreateTaskAppliesStatusAndAssigneeAndIgnoresPriority`)
|
||||
Expected : FAIL (assignee/status non appliqués, priorityId encore lu).
|
||||
|
||||
- [ ] **Step 3 : Modifier le contrôleur**
|
||||
|
||||
Dans `MailCreateTaskController.php` :
|
||||
|
||||
a) Remplacer l'import `use App\Entity\TaskPriority;` par :
|
||||
```php
|
||||
use App\Entity\TaskStatus;
|
||||
use App\Entity\User;
|
||||
```
|
||||
|
||||
b) Dans la transaction (l.62-96), **remplacer** le bloc priorité (l.77-82) par l'assigné + le statut :
|
||||
|
||||
```php
|
||||
if (isset($body['assigneeId']) && null !== $body['assigneeId']) {
|
||||
$assignee = $this->em->getRepository(User::class)->find($body['assigneeId']);
|
||||
if (null !== $assignee) {
|
||||
$task->setAssignee($assignee);
|
||||
}
|
||||
}
|
||||
|
||||
// Statut : celui fourni, sinon le premier statut du workflow du projet (par position)
|
||||
$status = null;
|
||||
if (isset($body['statusId']) && null !== $body['statusId']) {
|
||||
$status = $this->em->getRepository(TaskStatus::class)->find($body['statusId']);
|
||||
}
|
||||
if (null === $status) {
|
||||
$status = $project->getWorkflow()?->getStatuses()->first() ?: null;
|
||||
}
|
||||
if (null !== $status) {
|
||||
$task->setStatus($status);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Lancer le test (doit passer)**
|
||||
|
||||
Run: `make test`
|
||||
Expected : PASS. Lancer aussi `make php-cs-fixer-allow-risky`.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add src/Controller/Mail/MailCreateTaskController.php tests/Functional/Controller/Mail/MailTaskIntegrationControllerTest.php
|
||||
git commit -m "feat(mail) : create-task applique statut + assigné, retire la priorité"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 : Modale de création depuis un mail (#6 front)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/mail/MailCreateTaskModal.vue`
|
||||
- Modify: `frontend/services/mail.ts` (`createTaskFromMail`, ~l.184-192)
|
||||
- Modify: `frontend/i18n/locales/*.json` (libellés user/statut)
|
||||
|
||||
- [ ] **Step 1 : Adapter le service `createTaskFromMail`**
|
||||
|
||||
Dans `frontend/services/mail.ts`, modifier le payload de `createTaskFromMail` : retirer `priority`, accepter `assigneeId?: number` et `statusId?: number`. Le corps POST devient :
|
||||
|
||||
```ts
|
||||
{
|
||||
projectId,
|
||||
taskGroupId,
|
||||
assigneeId,
|
||||
statusId,
|
||||
}
|
||||
```
|
||||
|
||||
(adapter la signature TypeScript de la fonction en conséquence ; supprimer toute référence à `priority`).
|
||||
|
||||
- [ ] **Step 2 : Réécrire `MailCreateTaskModal.vue` sur AppModal + user + statut**
|
||||
|
||||
Remplacer le `<script setup>` : retirer `useTaskPriorityService`/`priorities`/`priorityId`/`priorityOptions`, ajouter le service users, le service statuts par workflow, et l'état `assigneeId` / `statusId`.
|
||||
|
||||
```ts
|
||||
import type { MailMessageDetailDto } from '~/services/dto/mail'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
messageId: number
|
||||
messageDetail: MailMessageDetailDto | null
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [task: Task] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const auth = useAuthStore()
|
||||
const mailService = useMailService()
|
||||
const projectService = useProjectService()
|
||||
const taskGroupService = useTaskGroupService()
|
||||
const userService = useUserService()
|
||||
|
||||
const projectId = ref<number | null>(null)
|
||||
const taskGroupId = ref<number | null>(null)
|
||||
const assigneeId = ref<number | null>(null)
|
||||
const statusId = ref<number | null>(null)
|
||||
const isSubmitting = ref(false)
|
||||
const touchedProject = ref(false)
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<{ id: number, username: string }[]>([])
|
||||
const loadingGroups = ref(false)
|
||||
|
||||
const projectOptions = computed(() => projects.value.map(p => ({ label: p.name, value: p.id })))
|
||||
const groupOptions = computed(() => groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })))
|
||||
const userOptions = computed(() => users.value.map(u => ({ label: u.username, value: u.id })))
|
||||
|
||||
// Statuts filtrés par le workflow du projet sélectionné (#2 réutilisé)
|
||||
const selectedProject = computed(() => projects.value.find(p => p.id === projectId.value) ?? null)
|
||||
const statusOptions = computed(() =>
|
||||
(selectedProject.value?.workflow?.statuses ?? []).map(s => ({ label: s.label, value: s.id })),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const [projs, us] = await Promise.all([
|
||||
projectService.getAll({ archived: false }),
|
||||
userService.getAll(),
|
||||
])
|
||||
projects.value = projs
|
||||
users.value = us
|
||||
})
|
||||
|
||||
// Au changement de projet : recharger les groupes + présélectionner le 1er statut du workflow
|
||||
watch(projectId, async (pid) => {
|
||||
taskGroupId.value = null
|
||||
statusId.value = selectedProject.value?.workflow?.statuses?.[0]?.id ?? null
|
||||
groups.value = []
|
||||
if (!pid) return
|
||||
loadingGroups.value = true
|
||||
try {
|
||||
groups.value = await taskGroupService.getByProject(pid)
|
||||
} finally {
|
||||
loadingGroups.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Reset + user par défaut = utilisateur connecté
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
projectId.value = null
|
||||
taskGroupId.value = null
|
||||
statusId.value = null
|
||||
assigneeId.value = auth.user?.id ?? null
|
||||
touchedProject.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function close(): void { emit('update:modelValue', false) }
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
touchedProject.value = true
|
||||
if (!projectId.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const task = await mailService.createTaskFromMail(props.messageId, {
|
||||
projectId: projectId.value,
|
||||
taskGroupId: taskGroupId.value ?? undefined,
|
||||
assigneeId: assigneeId.value ?? undefined,
|
||||
statusId: statusId.value ?? undefined,
|
||||
})
|
||||
emit('created', task)
|
||||
close()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Puis remplacer tout le `<template>` (et le `<style>` devient inutile — AppModal gère l'animation) par :
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<AppModal :model-value="modelValue" width="lg" :title="t('mail.createTaskModal.title')" @update:model-value="emit('update:modelValue', $event)">
|
||||
<div class="space-y-5">
|
||||
<div v-if="messageDetail" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm">
|
||||
<p class="truncate font-medium text-neutral-800">{{ messageDetail.header.subject ?? t('mail.noSubject') }}</p>
|
||||
<p class="mt-0.5 truncate text-xs text-neutral-500">{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}</p>
|
||||
<p class="mt-2 text-xs italic text-neutral-400">{{ t('mail.createTaskModal.titleHint') }}</p>
|
||||
<p class="text-xs italic text-neutral-400">{{ t('mail.createTaskModal.descriptionHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioSelect v-model="projectId" :options="projectOptions" :label="t('mail.createTaskModal.projectLabel')" :empty-option-label="t('mail.createTaskModal.projectPlaceholder')" min-width="w-full" />
|
||||
<p v-if="touchedProject && !projectId" class="mt-1 text-xs text-red-500">{{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis</p>
|
||||
</div>
|
||||
|
||||
<div v-if="projectId">
|
||||
<MalioSelect v-model="taskGroupId" :options="groupOptions" :label="t('mail.createTaskModal.groupLabel')" :empty-option-label="t('mail.createTaskModal.groupPlaceholder')" min-width="w-full" :disabled="loadingGroups" />
|
||||
</div>
|
||||
|
||||
<div v-if="projectId">
|
||||
<MalioSelect v-model="statusId" :options="statusOptions" :label="t('mail.createTaskModal.statusLabel')" min-width="w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" min-width="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton variant="tertiary" label="Annuler" button-class="w-auto px-4" @click="close" />
|
||||
<MalioButton :label="t('mail.createTaskModal.submit')" button-class="w-auto px-6" :disabled="isSubmitting" @click="handleSubmit" />
|
||||
</template>
|
||||
</AppModal>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Ajouter les clés i18n**
|
||||
|
||||
Dans `mail.createTaskModal` (toutes les locales) : `statusLabel` (« Statut »), `assigneeLabel` (« Assigné à »), `assigneePlaceholder` (« Aucun »). Retirer `priorityLabel`/`priorityPlaceholder` si plus utilisées ailleurs.
|
||||
|
||||
- [ ] **Step 4 : Build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected : exit 0.
|
||||
|
||||
- [ ] **Step 5 : Vérification navigateur (Chrome MCP)**
|
||||
|
||||
1. Hard-reload, login, Messagerie → ouvrir un message → « Créer une tâche ».
|
||||
Expected : modale **élargie**, footer **toujours visible**, champs = Projet / Groupe / **Statut** / **Assigné** (défaut = Matthieu). Plus de champ Priorité.
|
||||
2. Choisir un projet → le statut se présélectionne sur le 1er statut du workflow ; les options statut = celles du workflow du projet.
|
||||
3. Créer la tâche → succès, tâche liée au mail avec le bon statut/assigné.
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/mail/MailCreateTaskModal.vue frontend/services/mail.ts frontend/i18n/locales/
|
||||
git commit -m "feat(mail) : création de tâche depuis mail — sélecteur user + statut (workflow), modale agrandie"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7 : Supprimer le bouton « Lier un mail » (#5)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/task/TaskModal.vue` (bouton ~l.487-493 ; `<MailPickerModal>` ~l.498-503 ; état `showMailPickerModal` l.627 ; `handleMailLinked` ~l.936-938)
|
||||
- Delete: `frontend/components/mail/MailPickerModal.vue`
|
||||
- Modify: `frontend/i18n/locales/*.json` (clé `mail.taskTab.linkButton`)
|
||||
|
||||
**Contexte vérifié :** `MailPickerModal` n'est utilisé **que** par TaskModal.
|
||||
|
||||
- [ ] **Step 1 : Retirer le bouton, la modale, l'état et le handler dans TaskModal.vue**
|
||||
|
||||
- Supprimer le `<MalioButton ... :label="$t('mail.taskTab.linkButton')" ... @click="showMailPickerModal = true" />` (~l.487-493).
|
||||
- Supprimer le bloc `<MailPickerModal ... v-model="showMailPickerModal" ... @linked="handleMailLinked" />` (~l.498-503).
|
||||
- Supprimer `const showMailPickerModal = ref(false)` (l.627).
|
||||
- Supprimer la fonction `handleMailLinked` (~l.936-938).
|
||||
- Retirer l'éventuel `import MailPickerModal` (si import explicite ; sinon auto-import, rien à faire).
|
||||
|
||||
- [ ] **Step 2 : Supprimer le composant et la clé i18n**
|
||||
|
||||
```bash
|
||||
git rm frontend/components/mail/MailPickerModal.vue
|
||||
```
|
||||
Retirer la clé `mail.taskTab.linkButton` dans toutes les locales (vérifier qu'elle n'est plus référencée : `grep -rn "taskTab.linkButton" frontend/`).
|
||||
|
||||
- [ ] **Step 3 : Build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected : exit 0, aucune référence cassée.
|
||||
|
||||
- [ ] **Step 4 : Vérification navigateur (Chrome MCP)**
|
||||
|
||||
Ouvrir une tâche → onglet « Mails ».
|
||||
Expected : plus de bouton « Lier un mail ». La liste des mails liés et le bouton de suppression de lien (s'il existe) restent fonctionnels.
|
||||
|
||||
- [ ] **Step 5 : Commit**
|
||||
|
||||
```bash
|
||||
git add -A frontend/components/task/TaskModal.vue frontend/i18n/locales/
|
||||
git commit -m "refactor(task) : suppression du bouton « Lier un mail » et de MailPickerModal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8 : Cartes responsive (#3)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/task/TaskCard.vue` (ligne badges ~l.42-106)
|
||||
|
||||
**Contexte vérifié :** badges en `rounded-full px-2 py-0.5 ... text-white` sans contrainte ; conteneur `mt-2 flex items-center gap-1.5` sans `min-w-0` ni `flex-wrap`. Décision : **2-3 tags max + « +N »**, hauteur fixe, troncature.
|
||||
|
||||
- [ ] **Step 1 : Titre — `line-clamp-2`**
|
||||
|
||||
Ligne 30, remplacer :
|
||||
```vue
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
```
|
||||
par :
|
||||
```vue
|
||||
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Conteneur badges — `min-w-0` + troncature des badges**
|
||||
|
||||
Sur le conteneur (l.42) ajouter `min-w-0` : `class="mt-2 flex min-w-0 items-center gap-1.5"`.
|
||||
|
||||
Sur les badges statut/priorité/tag/deadline, ajouter `max-w-[7rem] truncate shrink-0` à la classe `rounded-full ...`. Exemple pour le statut (l.45) :
|
||||
```vue
|
||||
class="shrink-0 max-w-[7rem] truncate rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Limiter les tags à 2-3 + badge « +N »**
|
||||
|
||||
Remplacer la boucle des tags (l.57-64) par :
|
||||
```vue
|
||||
<span
|
||||
v-for="tag in task.tags.slice(0, 2)"
|
||||
:key="tag.id"
|
||||
class="shrink-0 max-w-[7rem] truncate rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
:style="{ backgroundColor: tag.color }"
|
||||
:title="tag.label"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="task.tags.length > 2"
|
||||
class="shrink-0 rounded-full bg-neutral-200 px-2 py-0.5 text-xs font-semibold text-neutral-600"
|
||||
:title="task.tags.slice(2).map(t => t.label).join(', ')"
|
||||
>
|
||||
+{{ task.tags.length - 2 }}
|
||||
</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected : exit 0.
|
||||
|
||||
- [ ] **Step 5 : Vérification navigateur (Chrome MCP)**
|
||||
|
||||
Sur « Mes tâches » avec données prod (cartes à nombreux tags) : vérifier via le DOM qu'aucune carte ne déborde (mesurer `scrollWidth - clientWidth` ≤ 1 sur la ligne de badges) ; les cartes à >2 tags montrent un badge « +N » ; titres longs tronqués sur 2 lignes.
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/task/TaskCard.vue
|
||||
git commit -m "fix(task) : cartes responsive — troncature badges, max 2 tags + « +N », titre line-clamp"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9 : Couleurs par défaut par catégorie + migration data prod (#4a + #4c)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/admin/WorkflowDrawer.vue` (`addStatus`, l.172-180 ; `categoryOptions` l.143-151)
|
||||
- Create: `migrations/VersionYYYYMMDDHHMMSS.php`
|
||||
- Modify: `src/DataFixtures/AppFixtures.php` (déjà correct — vérifier, ne rien changer si OK)
|
||||
|
||||
- [ ] **Step 1 : Couleur par défaut par catégorie dans `addStatus` (front)**
|
||||
|
||||
Dans `WorkflowDrawer.vue`, importer la palette et l'utiliser à la création :
|
||||
|
||||
```ts
|
||||
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
|
||||
```
|
||||
```ts
|
||||
function addStatus() {
|
||||
form.statuses.push({
|
||||
label: '',
|
||||
color: STATUS_CATEGORY_COLOR.todo, // défaut cohérent (catégorie initiale = todo)
|
||||
position: form.statuses.length,
|
||||
isFinal: false,
|
||||
category: 'todo',
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Et, pour aligner la couleur quand l'utilisateur change la catégorie d'un statut, ajouter un watcher dans le `<script setup>` :
|
||||
|
||||
```ts
|
||||
import type { StatusCategory } from '~/services/dto/workflow'
|
||||
// (déjà importé pour le type ; sinon ajouter)
|
||||
|
||||
watch(() => form.statuses.map(s => s.category), (cats, prev) => {
|
||||
cats.forEach((cat, i) => {
|
||||
// si la catégorie vient de changer ET que la couleur correspond encore au défaut de l'ancienne catégorie, réaligner
|
||||
if (prev && cat !== prev[i] && form.statuses[i] && form.statuses[i].color === STATUS_CATEGORY_COLOR[prev[i] as StatusCategory]) {
|
||||
form.statuses[i].color = STATUS_CATEGORY_COLOR[cat]
|
||||
}
|
||||
})
|
||||
}, { deep: false })
|
||||
```
|
||||
|
||||
> Ce watcher ne réécrase **pas** une couleur personnalisée (il n'agit que si la couleur courante = défaut de l'ancienne catégorie).
|
||||
|
||||
- [ ] **Step 2 : Build + vérif front**
|
||||
|
||||
Run: `cd frontend && npm run build:dist` (exit 0). Vérifier en navigateur : ajouter un statut → couleur par défaut indigo ; changer sa catégorie vers « En cours » alors qu'il a la couleur par défaut → la couleur passe au bleu `#4A90D9`.
|
||||
|
||||
- [ ] **Step 3 : Générer la migration de correction data**
|
||||
|
||||
Run: `make shell` puis `php bin/console make:migration` **n'est pas adapté** (pas de diff de schéma). Créer manuellement `migrations/VersionYYYYMMDDHHMMSS.php` (timestamp courant) :
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class VersionYYYYMMDDHHMMSS extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Remet les couleurs classiques sur les statuts du workflow Standard (dérive data prod #4).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Cible : statuts du workflow nommé "Standard", par catégorie. Ne touche pas aux autres workflows.
|
||||
$map = [
|
||||
'todo' => '#222783',
|
||||
'in_progress' => '#4A90D9',
|
||||
'blocked' => '#C62828',
|
||||
'review' => '#FF8F00',
|
||||
'done' => '#26A69A',
|
||||
];
|
||||
foreach ($map as $category => $hex) {
|
||||
$this->addSql(
|
||||
"UPDATE task_status SET color = :hex
|
||||
WHERE category = :cat
|
||||
AND workflow_id = (SELECT id FROM workflow WHERE name = 'Standard' ORDER BY id ASC LIMIT 1)",
|
||||
['hex' => $hex, 'cat' => $category]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Pas de rollback des couleurs (correction one-shot).
|
||||
$this->throwIrreversibleMigration('Correction de couleurs non réversible.');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Vérifier la signature `addSql` avec paramètres nommés de la version Doctrine Migrations utilisée ; sinon utiliser des valeurs inline (couleurs et catégories sont des constantes sûres). Confirmer le nom de colonne `workflow_id` via `\d task_status`.
|
||||
|
||||
- [ ] **Step 4 : Tester la migration en local (sur données prod importées)**
|
||||
|
||||
Run: `make migration-migrate`
|
||||
Puis vérifier :
|
||||
```bash
|
||||
docker exec -e PGPASSWORD=root lesstime-db-1 psql -U root -p 5435 -d lesstime -c "select label,color from task_status ts join workflow w on w.id=ts.workflow_id where w.name='Standard' order by ts.position;"
|
||||
```
|
||||
Expected : `#222783 / #4A90D9 / #C62828 / #FF8F00 / #26A69A`.
|
||||
|
||||
- [ ] **Step 5 : Vérif navigateur**
|
||||
|
||||
Kanban d'un projet Standard + badges de cartes : couleurs classiques de retour.
|
||||
|
||||
- [ ] **Step 6 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/admin/WorkflowDrawer.vue migrations/
|
||||
git commit -m "fix(workflow) : couleurs par défaut par catégorie + migration de correction du workflow Standard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10 : Migrer TaskModal vers AppModal (#7)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/task/TaskModal.vue` (coque de la modale uniquement : Teleport/Transition/overlay + header + footer)
|
||||
|
||||
> À faire en dernier car TaskModal est touché par #2 et #5 ; on stabilise d'abord son contenu. La migration ne change que la **coque** (structure header/body/footer), pas la logique métier.
|
||||
|
||||
- [ ] **Step 1 : Remplacer la coque par AppModal**
|
||||
|
||||
Envelopper le contenu existant dans `<AppModal :model-value="isOpen" width="lg" @update:model-value="isOpen = $event">`, déplacer le titre dans le slot `#title` (ou prop `title`), placer le corps actuel dans le slot par défaut et la barre d'actions (Supprimer / Annuler / Enregistrer, ~l.507-549) dans `<template #footer>`. Retirer le `Teleport`/`Transition`/overlay et le `max-h`/`overflow` manuels désormais gérés par AppModal.
|
||||
|
||||
> Conserver tels quels les sous-modales internes (ConfirmDeleteTaskModal, etc.) et la logique `close()` (qui bloque la fermeture si une confirmation est ouverte) — la connecter au `@update:model-value` d'AppModal.
|
||||
|
||||
- [ ] **Step 2 : Build**
|
||||
|
||||
Run: `cd frontend && npm run build:dist`
|
||||
Expected : exit 0.
|
||||
|
||||
- [ ] **Step 3 : Vérification navigateur (Chrome MCP)**
|
||||
|
||||
Ouvrir une tâche avec beaucoup de contenu (description longue) sur un viewport normal.
|
||||
Expected : header et **footer (Supprimer/Annuler/Enregistrer) toujours visibles**, body scrollable au milieu. Mesurer que le bouton « Enregistrer » est dans le viewport (`getBoundingClientRect().bottom <= window.innerHeight`).
|
||||
|
||||
- [ ] **Step 4 : Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/components/task/TaskModal.vue
|
||||
git commit -m "refactor(task) : TaskModal migré sur AppModal (footer sticky)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review — couverture spec
|
||||
|
||||
| Chantier spec | Task(s) | Couvert |
|
||||
|---|---|---|
|
||||
| #1 D&D | Task 4 | ✅ handlers + popover + par-workflow |
|
||||
| #2 Sélecteur statut | Task 3 (+ réutilisé Task 6) | ✅ |
|
||||
| #3 Cartes responsive | Task 8 | ✅ troncature + N |
|
||||
| #4 Couleurs | Task 1 (palette), Task 4 (entêtes), Task 9 (migration + défauts) | ✅ a/b/c |
|
||||
| #5 Bouton lier mail | Task 7 | ✅ |
|
||||
| #6 Création depuis mail | Task 5 (back) + Task 6 (front) | ✅ |
|
||||
| #7 Modale réutilisable | Task 2 (composant) + Task 6/10 (migrations) | ✅ |
|
||||
| #8 MalioSelect catégorie | déjà fait (hors plan) | ✅ |
|
||||
|
||||
**Risques / points de vigilance pour l'exécutant :**
|
||||
- Noms de fonctions/variables existants dans `my-tasks.vue` (rechargement des tâches, toast, `t`) et `TaskModal.vue` (projet effectif) — à raccorder aux noms réels.
|
||||
- `MailMessage` non garanti dans les fixtures → adapter le test backend (Task 5) ou importer un dump mail.
|
||||
- Toujours **hard-reload sans cache** après chaque `build:dist`.
|
||||
@@ -1,239 +0,0 @@
|
||||
# Specs — Correctifs UI suite au système de workflow + UX mail/modales
|
||||
|
||||
> Date : 2026-05-20
|
||||
> Contexte : suite à l'introduction des **workflows** (`docs/superpowers/specs/2026-05-19-project-workflows-design.md`),
|
||||
> plusieurs régressions UI et points UX sont apparus. Reviews faites par Lucile Schnödt et Tristan Schnödtin.
|
||||
> Ce document liste les 7 chantiers à traiter, avec problème, fichiers concernés, solution validée et points ouverts.
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
| # | Chantier | Type | Décision |
|
||||
|---|----------|------|----------|
|
||||
| 1 | Drag & drop cassé dans « mes tâches » | régression workflow | Drop = changer de statut (gérer ambiguïté multi-statuts) |
|
||||
| 2 | Sélecteur de statut mélange les workflows | régression workflow | Filtrer par le workflow du projet de la tâche |
|
||||
| 3 | Cartes de tâche non responsive (tags) | UI | Refonte responsive + troncature |
|
||||
| 4 | Couleurs de statut du workflow de base | data/UI | Remettre la palette classique |
|
||||
| 5 | Bouton « lier un mail » dans l'onglet mail d'un ticket | UX mail | Supprimer le bouton |
|
||||
| 6 | Création de ticket depuis un mail | UX mail | + sélecteur user, + sélecteur statut (remplace priorité), modale agrandie |
|
||||
| 7 | Footer collant des modales centrées | UX global | Composant modale réutilisable (header / body scrollable / footer sticky) |
|
||||
| 8 | Sélecteur de catégorie en `<select>` natif (WorkflowDrawer) | UI/dette | Migrer vers `MalioSelect` (la lib supporte les valeurs `string`) |
|
||||
|
||||
## Décisions actées (2026-05-21)
|
||||
|
||||
Suite à la reproduction des bugs sur données prod (import local) et discussion :
|
||||
|
||||
- **#1** — Désambiguïsation au drop : **popover** de choix quand la catégorie cible a ≥2 statuts (0 → drop refusé + feedback ; 1 → PATCH direct ; ≥2 → popover ancré au point de drop). Résolution **par tâche** (workflow de son projet).
|
||||
- **#2** — Source des statuts : `project.workflow.statuses` (déjà embarqué dans `GET /projects` et `task.project.workflow`). Le statut courant est ajouté en tête s'il est hors du workflow (pas de perte à l'enregistrement).
|
||||
- **#3** — Cartes : `min-w-0` partout, titre `line-clamp-2`, badges `truncate`, **2-3 tags max + badge « +N »** (tooltip), hauteur de carte fixe.
|
||||
- **#4** — Deux facettes :
|
||||
- **(a) Statuts** (badges cartes + kanban projet) : dérive **data en prod** (fixtures OK). Correction par **migration Doctrine** idempotente remettant les hex classiques sur le workflow Standard.
|
||||
- **(b) Catégories** (entêtes multi-projets de « Mes tâches », aujourd'hui grises) : nouvelle constante front **`STATUS_CATEGORY_COLOR`** (5 hex classiques) → **bandeau teinté** sur les entêtes, **texte auto noir/blanc** selon la luminance.
|
||||
- **(c)** Couleur par défaut **par catégorie** dans `addStatus()` (au lieu de `#222783` systématique) pour éviter une nouvelle dérive.
|
||||
- **#5** — Suppression du bouton « Lier un mail » + `MailPickerModal` + état/handler + clé i18n (`MailPickerModal` n'est utilisé que par TaskModal — vérifié).
|
||||
- **#6** — + sélecteur **user** (défaut pré-rempli) ; priorité **remplacée** par sélecteur **statut** (filtré workflow, rechargé au changement de projet) ; **`priority`/`priorityId` retiré** du payload et de l'endpoint backend ; statut par défaut = **1er statut du workflow par `position`** ; modale élargie via #7.
|
||||
- **#7** — Composant `frontend/components/ui/AppModal.vue` (header fixe / body `flex-1 min-h-0 overflow-y-auto` / footer `shrink-0` sticky, `max-h-[90vh]`, prop `width`). Migration d'abord : TaskModal puis MailCreateTaskModal.
|
||||
- **#8** — ✅ Fait : `<select>` catégorie → `MalioSelect` (la lib accepte `value: string | number | null` ; note CLAUDE.md corrigée).
|
||||
|
||||
---
|
||||
|
||||
## 1. Drag & drop cassé dans « mes tâches »
|
||||
|
||||
**Problème.** Le drag & drop des cartes entre colonnes ne fonctionne plus depuis l'arrivée des workflows.
|
||||
|
||||
**Fichiers.**
|
||||
- `frontend/pages/my-tasks.vue` — colonnes kanban construites sur les **catégories canoniques** fixes (`CATEGORIES = ['todo','in_progress','blocked','review','done']`, ~l.118-127), template kanban ~l.396-424.
|
||||
- `frontend/components/task/TaskCard.vue` — `@dragstart` / `@dragend` HTML5 natif (~l.154-162), `dataTransfer` = `task.id`.
|
||||
- Pas de librairie externe (HTML5 natif).
|
||||
|
||||
**Cause racine.** Les colonnes sont des **catégories canoniques** (la vue agrège plusieurs projets/workflows, donc on ne peut pas afficher une colonne par statut d'un workflow précis). Or un workflow peut désormais mapper **plusieurs statuts sur une même catégorie** (ex. deux statuts « in_progress »). Au drop dans une colonne, le statut cible devient ambigu — ce qui a cassé / rendu indéterminé le changement de statut.
|
||||
|
||||
**Solution retenue.** Drop dans une colonne ⇒ change le statut de la tâche vers un statut de cette catégorie **dans le workflow du projet de la tâche**. Gestion de l'ambiguïté :
|
||||
- Récupérer les statuts du workflow du projet de la tâche déposée, filtrés par la catégorie de la colonne cible.
|
||||
- **0 statut** dans cette catégorie pour ce workflow → drop refusé (feedback visuel, pas de changement).
|
||||
- **1 statut** → appliquer directement.
|
||||
- **≥ 2 statuts** → afficher un mini-sélecteur (popover/menu) au point de drop pour choisir le statut exact.
|
||||
|
||||
**Points ouverts.**
|
||||
- ⚠️ **À trancher demain** : confirmer la stratégie de désambiguïsation (popover au drop vs. choisir le statut de plus petite `position` dans la catégorie). Le popover est plus sûr mais plus de travail ; le « plus petite position » est transparent mais peut surprendre.
|
||||
- Ajouter les **handlers de drop** sur les colonnes (`@dragover.prevent`, `@drop`) — actuellement absents dans `my-tasks.vue`.
|
||||
- Comme la vue est multi-projets, la résolution du statut cible doit se faire **par tâche** (selon son projet → son workflow), pas globalement.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sélecteur de statut mélange les statuts de tous les workflows
|
||||
|
||||
**Problème.** En ouvrant une tâche (modale), le sélecteur « Statut » liste **tous les statuts globaux**, donc ceux du workflow de base **et** ceux des autres workflows.
|
||||
|
||||
**Fichiers.**
|
||||
- `frontend/components/task/TaskModal.vue` — `<MalioSelect v-model="form.statusId" :options="statusOptions" />` (~l.103-109) ; `statusOptions = props.statuses.map(...)` (~l.674-676).
|
||||
- `frontend/pages/my-tasks.vue` — `:statuses="statuses"` (~l.489), chargés via `statusService.getAll()` (~l.132).
|
||||
- `frontend/services/task-statuses.ts` — `getAll()` → `GET /task_statuses` (tous les statuts).
|
||||
- DTO : `frontend/services/dto/task-status.ts` — le champ `workflow` existe déjà (`{ '@id', id } | string`).
|
||||
|
||||
**Solution retenue.** Le sélecteur ne doit proposer que les statuts du **workflow du projet de la tâche éditée**.
|
||||
- Filtrer `statusOptions` sur `status.workflow` correspondant au workflow du projet de la tâche.
|
||||
- Source du filtre : soit filtrer côté front la liste déjà chargée par `workflow.id`, soit charger les statuts du workflow via le service workflow (`useWorkflowService()` existe mais n'est pas utilisé ici).
|
||||
- S'assurer que le statut courant de la tâche reste affiché même si édge case (ex. statut hérité d'un ancien workflow).
|
||||
|
||||
**Points ouverts.**
|
||||
- Vérifier que la tâche/le projet expose bien l'`@id`/`id` du workflow côté payload pour faire le filtre sans appel supplémentaire.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cartes de tâche non responsive (tags mal placés / trop gros)
|
||||
|
||||
**Problème.** Depuis l'ajout d'éléments dans la carte (statut, priorité, effort, tags, deadline, avatars…), les tags débordent ou se chevauchent quand les textes sont longs ; la carte n'est plus responsive.
|
||||
|
||||
**Fichiers.**
|
||||
- `frontend/components/task/TaskCard.vue` — badges statut (~l.43-49) et tags (~l.58-64) : `rounded-full px-2 py-0.5 text-xs font-semibold text-white`, **sans `truncate`, sans `max-w`, sans `line-clamp`** ; conteneur `flex` sans contrainte de largeur (~l.42-106).
|
||||
|
||||
**Solution retenue.** Refonte du layout de la carte pour rester contenue quelle que soit la longueur des textes :
|
||||
- Conteneur des badges en `flex flex-wrap gap-1` (retour à la ligne propre).
|
||||
- Tags : `max-w-[...] truncate` (ou `line-clamp-1`) + `title`/tooltip pour le texte complet au survol.
|
||||
- Hiérarchiser l'info : titre prioritaire (`line-clamp-2`), badges secondaires qui passent à la ligne ou se condensent.
|
||||
- Option : limiter le nombre de tags affichés (ex. 2-3 + « +N »).
|
||||
|
||||
**Points ouverts.**
|
||||
- ⚠️ Choix d'UX à valider : `flex-wrap` (tous les tags visibles, carte plus haute) vs. troncature « +N » (hauteur fixe). Décision visuelle à prendre demain (éventuellement via mockup).
|
||||
|
||||
---
|
||||
|
||||
## 4. Couleurs de statut du workflow de base à remettre
|
||||
|
||||
**Problème.** Les couleurs « classiques » des statuts du workflow de base ont changé ; il faut remettre les couleurs d'origine.
|
||||
|
||||
**Investigation faite.** Le commit `4775cbf` (palette élargie 9→18 teintes + couleur perso) ne touche **que** `ColorPicker.vue` et `ProjectDrawer.vue` — il n'a pas modifié les couleurs des statuts. Les couleurs d'origine du **workflow Standard** sont dans les fixtures :
|
||||
|
||||
| Catégorie | Statut | Hex classique |
|
||||
|---|---|---|
|
||||
| todo | À faire | `#222783` |
|
||||
| in_progress | En cours | `#4A90D9` |
|
||||
| blocked | Bloqué | `#C62828` |
|
||||
| review | En attente de validation | `#FF8F00` |
|
||||
| done | Terminé | `#26A69A` |
|
||||
|
||||
Source : `src/DataFixtures/AppFixtures.php:101-105` (statuts rattachés au `$standardWorkflow`, ~l.93-116).
|
||||
Migration de rattachement : `migrations/Version20260519175114.php` (attache les statuts existants au workflow Standard).
|
||||
|
||||
**Solution retenue.** Remettre ces 5 couleurs sur les statuts du **workflow Standard/de base**.
|
||||
- Vérifier en prod (et en base de dev si dérive) que les statuts du workflow Standard portent bien ces hex ; corriger ceux qui ont dérivé (via l'UI d'admin des statuts, ou un script/migration de correction des couleurs).
|
||||
- Ne **pas** toucher aux couleurs des statuts des autres workflows.
|
||||
|
||||
**Points ouverts.**
|
||||
- Confirmer où la dérive a eu lieu (prod vs. nouveaux workflows créés via l'UI avec d'autres couleurs). Si c'est un workflow Standard en prod avec des couleurs erronées → correction data ; si c'est par défaut à la création d'un workflow → ajuster les couleurs proposées par défaut.
|
||||
|
||||
---
|
||||
|
||||
## 5. Supprimer le bouton « lier un mail » dans l'onglet mail d'un ticket
|
||||
|
||||
**Problème.** En ouvrant un ticket, l'onglet mail propose un bouton « lier un mail » qui n'a pas d'utilité de ce côté.
|
||||
|
||||
**Fichiers.**
|
||||
- `frontend/components/task/TaskModal.vue` — bouton « lier un mail » (~l.486-494, `mail.taskTab.linkButton`, ouvre `showMailPickerModal`) ; `<MailPickerModal>` (~l.498-503), visible si `isEditing && isMailUser`, onglet `mails`.
|
||||
- Composant lié : `frontend/components/mail/MailPickerModal.vue`.
|
||||
|
||||
**Solution retenue.** Supprimer le bouton « lier un mail » de l'onglet mail du ticket.
|
||||
- Retirer le bouton et, si plus aucun usage, le `MailPickerModal` associé + l'état `showMailPickerModal` + `handleMailLinked`.
|
||||
- Nettoyer la clé i18n `mail.taskTab.linkButton` si elle n'est plus utilisée ailleurs.
|
||||
|
||||
**Points ouverts.**
|
||||
- Vérifier que `MailPickerModal` n'est pas réutilisé ailleurs avant de le supprimer (sinon ne retirer que le bouton).
|
||||
|
||||
---
|
||||
|
||||
## 6. Création d'un ticket depuis un mail
|
||||
|
||||
**Problème.** Le formulaire de création depuis un mail est incomplet et la modale dépasse de l'écran.
|
||||
|
||||
**Fichiers.**
|
||||
- `frontend/components/mail/MailCreateTaskModal.vue` — champs actuels (~l.119-224) : info source (lecture seule), Projet (requis), Groupe (optionnel), **Priorité** (optionnelle). Footer non sticky (~l.210-224).
|
||||
- `frontend/services/mail.ts` — `createTaskFromMail()` (~l.184-192) → `POST /mail/messages/{id}/create-task` avec `{ projectId, taskGroupId, priority }`.
|
||||
|
||||
**Solution retenue.**
|
||||
1. **Sélecteur de user (assigné).** Ajouter un sélecteur d'utilisateur. Un user par défaut est déjà appliqué ; le sélecteur permet d'en choisir un autre. (Charger la liste via le service users.)
|
||||
2. **Statut à la place de la priorité.** **Retirer** le sélecteur de priorité, le **remplacer** par un sélecteur de statut. Le statut proposé doit respecter le workflow du projet sélectionné (cf. chantier #2 — réutiliser la même logique de filtrage par workflow). Au changement de projet, recharger les statuts du workflow correspondant.
|
||||
3. **Agrandir la modale.** Élargir la largeur (et gérer la hauteur via body scrollable + footer sticky, cf. chantier #7) pour qu'elle ne dépasse plus.
|
||||
4. **Backend.** Adapter le payload / l'endpoint `create-task` : accepter `assigneeId` (ou IRI user) et `statusId` (ou IRI statut) ; retirer/garder `priority` selon décision (ici : remplacé par statut côté UI — décider si on garde la priorité par défaut côté backend ou non).
|
||||
|
||||
**Points ouverts.**
|
||||
- Confirmer le statut **par défaut** présélectionné (statut initial du workflow du projet).
|
||||
- Décider si la priorité disparaît totalement du payload ou reste à une valeur par défaut côté backend.
|
||||
|
||||
---
|
||||
|
||||
## 7. Footer collant pour les modales centrées
|
||||
|
||||
**Problème.** Les modales qui s'ouvrent au milieu de l'écran ont leurs boutons d'action en bas, qui défilent avec le contenu et finissent hors écran. On veut un footer **toujours visible**.
|
||||
|
||||
**Constat.** Il n'existe **aucun composant modale réutilisable** (`MalioModal`/`AppModal`). Chaque modale réimplémente `Teleport` + `Transition` + header/body/footer. Footers actuels en `border-t` mais **non sticky** :
|
||||
- `frontend/components/task/TaskModal.vue` (footer ~l.507-549)
|
||||
- `frontend/components/mail/MailCreateTaskModal.vue` (~l.210-224)
|
||||
- `frontend/components/mail/MailLinkTaskModal.vue` (~l.226-239)
|
||||
- `frontend/components/mail/MailPickerModal.vue` (~l.187-201)
|
||||
- `frontend/components/ui/ConfirmDeleteTaskModal.vue` (~l.11-24)
|
||||
- `frontend/components/project/ProjectWorkflowSwitchModal.vue` (~l.63-76)
|
||||
|
||||
**Solution retenue (décision : composant réutilisable).** Créer un composant modale réutilisable dans `frontend/components/ui/` (ex. `AppModal.vue` / `MalioModal.vue` local) :
|
||||
- Structure : `Teleport to="body"` + `Transition`, overlay `fixed inset-0`, conteneur avec **hauteur max** (`max-h-[90vh]`), en flex-col :
|
||||
- **header** (titre + fermeture) fixe en haut,
|
||||
- **body** `flex-1 overflow-y-auto`,
|
||||
- **footer** sticky en bas (`shrink-0 border-t`), via slot `#footer`.
|
||||
- Props : largeur (`sm`/`md`/`lg`/`xl`), titre, `v-model` ouverture ; slots `default` (body) et `footer`.
|
||||
- **Migration progressive** des modales existantes vers ce composant (commencer par celles citées par les reviews : TaskModal, MailCreateTaskModal). Ne pas tout migrer d'un coup.
|
||||
|
||||
**Points ouverts.**
|
||||
- Nom et emplacement définitifs du composant.
|
||||
- Ordre de migration (au minimum : MailCreateTaskModal #6 et TaskModal #2/#5, qui sont déjà touchées par les autres chantiers).
|
||||
|
||||
---
|
||||
|
||||
## 8. Sélecteur de catégorie en `<select>` natif (WorkflowDrawer) → MalioSelect
|
||||
|
||||
**Problème.** Dans l'éditeur de statut d'un workflow, le champ « Catégorie » est un `<select>` HTML natif, visuellement incohérent avec le reste des formulaires (qui utilisent `MalioSelect`).
|
||||
|
||||
**Investigation faite (2026-05-21).** La note de `Lesstime/CLAUDE.md` affirmait que `MalioSelect` n'accepte que des `value: number | null` et qu'il fallait un `<select>` natif pour les enums string. **C'est faux.** Vérifié dans la source `@malio/layer-ui` (v1.4.8 installée **et** repo dev `malio-layer-ui`) :
|
||||
|
||||
```ts
|
||||
// app/components/malio/select/Select.vue
|
||||
type Option = { label: string; value: string | number | null } // string supporté
|
||||
const props = withDefaults(defineProps<{ modelValue: string | number | null; ... }>(), ...)
|
||||
emit('update:modelValue', v: string | number | null)
|
||||
// normalizedOptions n'ajoute l'option vide {value:null} QUE si empty-option-label est passé
|
||||
```
|
||||
|
||||
La comparaison interne utilise `===`, donc les valeurs `string` (ex. l'enum `StatusCategory` : `todo | in_progress | blocked | review | done`) fonctionnent nativement.
|
||||
|
||||
**Fichiers.**
|
||||
- `frontend/components/admin/WorkflowDrawer.vue` — `<select v-model="s.category">` (~l.49-57), `categoryOptions: { value: StatusCategory, label }[]` (~l.145-151).
|
||||
- Note corrigée : `Lesstime/CLAUDE.md` (section « Frontend »).
|
||||
|
||||
**Solution retenue (faite).** Remplacer le `<select>` natif par :
|
||||
|
||||
```vue
|
||||
<MalioSelect
|
||||
v-model="s.category"
|
||||
:options="categoryOptions"
|
||||
label="Catégorie"
|
||||
min-width="w-44"
|
||||
group-class="shrink-0"
|
||||
/>
|
||||
```
|
||||
|
||||
- Pas d'`empty-option-label` (catégorie requise → pas d'option « Aucune » `null`).
|
||||
- `min-width="w-44"` pour rester compact dans la ligne `flex` (sinon défaut `w-96`).
|
||||
|
||||
**Points ouverts / suite possible.**
|
||||
- D'autres `<select>` natifs subsistent pour des enums string et pourraient être migrés de la même façon (candidats : `AdminClientTicketTab.vue`, `AdminMailTab.vue`, `ProjectClientTickets.vue`, `ProjectWorkflowSwitchModal.vue`, `TaskModal.vue`). À traiter au cas par cas, hors scope immédiat.
|
||||
|
||||
---
|
||||
|
||||
## Découpage suggéré pour l'implémentation
|
||||
|
||||
Regrouper par zone pour limiter les conflits :
|
||||
|
||||
- **Lot A — Workflow / statuts** : #2 (filtrage statut) → réutilisé par #1 (D&D) et #6 (statut à la création mail).
|
||||
- **Lot B — Cartes** : #3 (responsive) + #4 (couleurs classiques).
|
||||
- **Lot C — Mail** : #5 (suppr. bouton) + #6 (form création) — dépend de #2 et #7.
|
||||
- **Lot D — Modale réutilisable** : #7 (composant) puis migration de TaskModal et MailCreateTaskModal.
|
||||
|
||||
Ordre conseillé : **#7 (composant modale)** et **#2 (filtrage statut)** d'abord (briques réutilisées), puis #1, #6, #5, puis #3/#4.
|
||||
@@ -1,231 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-neutral-900">{{ $t('mail.admin.title') }}</h2>
|
||||
|
||||
<form class="mt-6 max-w-lg space-y-6" @submit.prevent="handleSave">
|
||||
<!-- Section IMAP (réception) -->
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-sm font-bold text-neutral-700">{{ $t('mail.admin.imapSection') }}</legend>
|
||||
|
||||
<div>
|
||||
<MalioInputText
|
||||
v-model="form.imapHost"
|
||||
:label="$t('mail.admin.host')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-neutral-500">{{ $t('mail.admin.ovhDefaultsHelp') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.port') }}</label>
|
||||
<input
|
||||
v-model.number="form.imapPort"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.encryption') }}</label>
|
||||
<select
|
||||
v-model="form.imapEncryption"
|
||||
class="mt-1 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option value="ssl">SSL</option>
|
||||
<option value="tls">TLS</option>
|
||||
<option value="none">Aucun</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Section SMTP (envoi) -->
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-sm font-bold text-neutral-700">{{ $t('mail.admin.smtpSection') }}</legend>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.smtpHost"
|
||||
:label="$t('mail.admin.host')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.port') }}</label>
|
||||
<input
|
||||
v-model.number="form.smtpPort"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-neutral-700">{{ $t('mail.admin.encryption') }}</label>
|
||||
<select
|
||||
v-model="form.smtpEncryption"
|
||||
class="mt-1 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
>
|
||||
<option value="ssl">SSL</option>
|
||||
<option value="tls">TLS</option>
|
||||
<option value="none">Aucun</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Credentials -->
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-sm font-bold text-neutral-700">{{ $t('mail.admin.username') }}</legend>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.username"
|
||||
:label="$t('mail.admin.username')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<MalioInputPassword
|
||||
v-model="form.password"
|
||||
:label="$t('mail.admin.password')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
||||
{{ $t('mail.admin.passwordSet') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MalioInputText
|
||||
v-model="form.sentFolderPath"
|
||||
:label="$t('mail.admin.sentFolderPath')"
|
||||
placeholder="Sent Messages"
|
||||
input-class="w-full"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input v-model="form.enabled" type="checkbox" class="rounded border-neutral-300" />
|
||||
<span class="text-sm">{{ $t('mail.admin.enabled') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<MalioButton
|
||||
:label="$t('mail.admin.save')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSaving"
|
||||
@click="handleSave"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
:label="$t('mail.admin.test')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isTesting"
|
||||
@click="handleTest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult !== null">
|
||||
<p
|
||||
class="text-sm font-medium"
|
||||
:class="testResult ? 'text-green-600' : 'text-red-600'"
|
||||
>
|
||||
{{ testResult ? $t('mail.admin.testSuccess') : $t('mail.admin.testFailed') }}
|
||||
</p>
|
||||
<p v-if="testResult === false && testError" class="mt-1 text-xs text-neutral-500">
|
||||
{{ testError }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMailService } from '~/services/mail'
|
||||
|
||||
const { getConfiguration, updateConfiguration, testConfiguration } = useMailService()
|
||||
|
||||
const form = reactive({
|
||||
protocol: 'imap',
|
||||
imapHost: '',
|
||||
imapPort: 993,
|
||||
imapEncryption: 'ssl',
|
||||
smtpHost: '',
|
||||
smtpPort: 465,
|
||||
smtpEncryption: 'ssl',
|
||||
username: '',
|
||||
password: '',
|
||||
sentFolderPath: '',
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const hasPassword = ref<boolean>(false)
|
||||
const isSaving = ref<boolean>(false)
|
||||
const isTesting = ref<boolean>(false)
|
||||
const testResult = ref<boolean | null>(null)
|
||||
const testError = ref<string | null>(null)
|
||||
|
||||
async function loadSettings(): Promise<void> {
|
||||
const config = await getConfiguration()
|
||||
form.protocol = config.protocol ?? 'imap'
|
||||
form.imapHost = config.imapHost ?? ''
|
||||
form.imapPort = config.imapPort ?? 993
|
||||
form.imapEncryption = config.imapEncryption ?? 'ssl'
|
||||
form.smtpHost = config.smtpHost ?? ''
|
||||
form.smtpPort = config.smtpPort ?? 465
|
||||
form.smtpEncryption = config.smtpEncryption ?? 'ssl'
|
||||
form.username = config.username ?? ''
|
||||
form.sentFolderPath = config.sentFolderPath ?? ''
|
||||
form.enabled = config.enabled
|
||||
hasPassword.value = config.hasPassword
|
||||
// password jamais pré-rempli
|
||||
}
|
||||
|
||||
async function handleSave(): Promise<void> {
|
||||
isSaving.value = true
|
||||
testResult.value = null
|
||||
testError.value = null
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
protocol: form.protocol,
|
||||
imapHost: form.imapHost.trim() || null,
|
||||
imapPort: form.imapPort,
|
||||
imapEncryption: form.imapEncryption,
|
||||
smtpHost: form.smtpHost.trim() || null,
|
||||
smtpPort: form.smtpPort,
|
||||
smtpEncryption: form.smtpEncryption,
|
||||
username: form.username.trim() || null,
|
||||
sentFolderPath: form.sentFolderPath.trim() || null,
|
||||
enabled: form.enabled,
|
||||
}
|
||||
if (form.password) {
|
||||
payload.password = form.password
|
||||
}
|
||||
const result = await updateConfiguration(payload)
|
||||
hasPassword.value = result.hasPassword
|
||||
form.password = ''
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(): Promise<void> {
|
||||
isTesting.value = true
|
||||
testResult.value = null
|
||||
testError.value = null
|
||||
try {
|
||||
const result = await testConfiguration()
|
||||
testResult.value = result.ok
|
||||
if (!result.ok && result.error) {
|
||||
testError.value = result.error
|
||||
}
|
||||
} catch {
|
||||
testResult.value = false
|
||||
} finally {
|
||||
isTesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
@@ -46,12 +46,15 @@
|
||||
label="Libellé"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
<select
|
||||
v-model="s.category"
|
||||
:options="categoryOptions"
|
||||
label="Catégorie"
|
||||
group-class="w-48 shrink-0"
|
||||
/>
|
||||
class="h-10 rounded border border-neutral-300 px-2 text-sm"
|
||||
aria-label="Catégorie"
|
||||
>
|
||||
<option v-for="c in categoryOptions" :key="c.value" :value="c.value">
|
||||
{{ c.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 px-2 text-red-600 hover:text-red-800"
|
||||
@@ -94,7 +97,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
|
||||
import { STATUS_CATEGORY_COLOR } from '~/services/dto/workflow'
|
||||
import type { TaskStatusWrite } from '~/services/dto/task-status'
|
||||
import { useWorkflowService } from '~/services/workflows'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
@@ -169,20 +171,10 @@ watch(() => props.modelValue, (open) => {
|
||||
touched.name = false
|
||||
})
|
||||
|
||||
watch(() => form.statuses.map(s => s.category), (cats, prev) => {
|
||||
if (!prev) return
|
||||
cats.forEach((cat, i) => {
|
||||
const s = form.statuses[i]
|
||||
if (s && cat !== prev[i] && s.color === STATUS_CATEGORY_COLOR[prev[i] as StatusCategory]) {
|
||||
s.color = STATUS_CATEGORY_COLOR[cat as StatusCategory]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function addStatus() {
|
||||
form.statuses.push({
|
||||
label: '',
|
||||
color: STATUS_CATEGORY_COLOR.todo,
|
||||
color: '#222783',
|
||||
position: form.statuses.length,
|
||||
isFinal: false,
|
||||
category: 'todo',
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageDetailDto } from '~/services/dto/mail'
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
import { useTaskGroupService } from '~/services/task-groups'
|
||||
import { useUserService } from '~/services/users'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
messageId: number
|
||||
messageDetail: MailMessageDetailDto | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
created: [task: Task]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const auth = useAuthStore()
|
||||
const mailService = useMailService()
|
||||
const projectService = useProjectService()
|
||||
const taskGroupService = useTaskGroupService()
|
||||
const userService = useUserService()
|
||||
|
||||
const projectId = ref<number | null>(null)
|
||||
const taskGroupId = ref<number | null>(null)
|
||||
const assigneeId = ref<number | null>(null)
|
||||
const statusId = ref<number | null>(null)
|
||||
const isSubmitting = ref(false)
|
||||
const touchedProject = ref(false)
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
const groups = ref<TaskGroup[]>([])
|
||||
const users = ref<UserData[]>([])
|
||||
const loadingGroups = ref(false)
|
||||
|
||||
const projectOptions = computed(() => projects.value.map(p => ({ label: p.name, value: p.id })))
|
||||
const groupOptions = computed(() => groups.value.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })))
|
||||
const userOptions = computed(() => users.value.map(u => ({ label: u.username, value: u.id })))
|
||||
|
||||
const selectedProject = computed(() => projects.value.find(p => p.id === projectId.value) ?? null)
|
||||
const statusOptions = computed(() =>
|
||||
(selectedProject.value?.workflow?.statuses ?? []).map(s => ({ label: s.label, value: s.id })),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const [projs, us] = await Promise.all([
|
||||
projectService.getAll({ archived: false }),
|
||||
userService.getAll(),
|
||||
])
|
||||
projects.value = projs
|
||||
users.value = us
|
||||
})
|
||||
|
||||
watch(projectId, async (pid) => {
|
||||
taskGroupId.value = null
|
||||
statusId.value = selectedProject.value?.workflow?.statuses?.[0]?.id ?? null
|
||||
groups.value = []
|
||||
if (!pid) return
|
||||
loadingGroups.value = true
|
||||
try {
|
||||
groups.value = await taskGroupService.getByProject(pid)
|
||||
} finally {
|
||||
loadingGroups.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
projectId.value = null
|
||||
taskGroupId.value = null
|
||||
statusId.value = null
|
||||
assigneeId.value = auth.user?.id ?? null
|
||||
touchedProject.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
touchedProject.value = true
|
||||
if (!projectId.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const task = await mailService.createTaskFromMail(props.messageId, {
|
||||
projectId: projectId.value,
|
||||
taskGroupId: taskGroupId.value ?? undefined,
|
||||
assigneeId: assigneeId.value ?? undefined,
|
||||
statusId: statusId.value ?? undefined,
|
||||
})
|
||||
emit('created', task)
|
||||
close()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppModal
|
||||
:model-value="modelValue"
|
||||
width="lg"
|
||||
:title="t('mail.createTaskModal.title')"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<div v-if="messageDetail" class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm">
|
||||
<p class="truncate font-medium text-neutral-800">{{ messageDetail.header.subject ?? t('mail.noSubject') }}</p>
|
||||
<p class="mt-0.5 truncate text-xs text-neutral-500">{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}</p>
|
||||
<p class="mt-2 text-xs italic text-neutral-400">{{ t('mail.createTaskModal.titleHint') }}</p>
|
||||
<p class="text-xs italic text-neutral-400">{{ t('mail.createTaskModal.descriptionHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioSelect v-model="projectId" :options="projectOptions" :label="t('mail.createTaskModal.projectLabel')" :empty-option-label="t('mail.createTaskModal.projectPlaceholder')" min-width="w-full" />
|
||||
<p v-if="touchedProject && !projectId" class="mt-1 text-xs text-red-500">{{ t('mail.createTaskModal.projectLabel').replace(' *', '') }} requis</p>
|
||||
</div>
|
||||
|
||||
<div v-if="projectId">
|
||||
<MalioSelect v-model="taskGroupId" :options="groupOptions" :label="t('mail.createTaskModal.groupLabel')" :empty-option-label="t('mail.createTaskModal.groupPlaceholder')" min-width="w-full" :disabled="loadingGroups" />
|
||||
</div>
|
||||
|
||||
<div v-if="projectId">
|
||||
<MalioSelect v-model="statusId" :options="statusOptions" :label="t('mail.createTaskModal.statusLabel')" min-width="w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<MalioSelect v-model="assigneeId" :options="userOptions" :label="t('mail.createTaskModal.assigneeLabel')" :empty-option-label="t('mail.createTaskModal.assigneePlaceholder')" min-width="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<MalioButton variant="tertiary" label="Annuler" button-class="w-auto px-4" @click="close" />
|
||||
<MalioButton :label="t('mail.createTaskModal.submit')" button-class="w-auto px-6" :disabled="isSubmitting" @click="handleSubmit" />
|
||||
</template>
|
||||
</AppModal>
|
||||
</template>
|
||||
@@ -1,123 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailFolderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Arbre de dossiers (getter folderTree du store) */
|
||||
folders: readonly MailFolderDto[]
|
||||
/** Chemin du dossier actuellement sélectionné */
|
||||
selectedPath: string | null
|
||||
/** Niveau de profondeur pour l'indentation (usage récursif interne) */
|
||||
depth?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [path: string]
|
||||
}>()
|
||||
|
||||
const { getFolderLabel, getFolderIcon } = useSystemFolderLabel()
|
||||
const { t } = useI18n()
|
||||
|
||||
const currentDepth = computed(() => props.depth ?? 0)
|
||||
|
||||
// Dossiers dépliés (repliés par défaut → seuls les dossiers racine sont visibles).
|
||||
const expanded = ref<Set<string>>(new Set())
|
||||
|
||||
function isExpanded(path: string): boolean {
|
||||
return expanded.value.has(path)
|
||||
}
|
||||
|
||||
function toggleExpanded(path: string): void {
|
||||
const next = new Set(expanded.value)
|
||||
if (next.has(path)) {
|
||||
next.delete(path)
|
||||
} else {
|
||||
next.add(path)
|
||||
}
|
||||
expanded.value = next
|
||||
}
|
||||
|
||||
function hasChildren(folder: MailFolderDto): boolean {
|
||||
return !!folder.children && folder.children.length > 0
|
||||
}
|
||||
|
||||
function handleSelect(path: string): void {
|
||||
emit('select', path)
|
||||
}
|
||||
|
||||
function paddingStyle(): Record<string, string> {
|
||||
const depth = currentDepth.value
|
||||
return { paddingLeft: `${0.5 + depth * 0.75}rem` }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="folders.length === 0 && currentDepth === 0"
|
||||
class="px-3 py-4 text-sm text-neutral-400 italic"
|
||||
>
|
||||
{{ t('mail.empty.folder') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-for="folder in folders" :key="folder.path">
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-md pr-2 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
selectedPath === folder.path
|
||||
? 'bg-primary-100 text-primary-700 font-medium'
|
||||
: 'text-neutral-700 hover:bg-neutral-100'
|
||||
"
|
||||
:style="paddingStyle()"
|
||||
>
|
||||
<button
|
||||
v-if="hasChildren(folder)"
|
||||
type="button"
|
||||
class="flex-shrink-0 rounded p-0.5 hover:bg-neutral-200"
|
||||
:aria-label="isExpanded(folder.path) ? t('mail.folderTree.collapse') : t('mail.folderTree.expand')"
|
||||
@click.stop="toggleExpanded(folder.path)"
|
||||
>
|
||||
<Icon
|
||||
:name="isExpanded(folder.path) ? 'material-symbols:keyboard-arrow-down' : 'material-symbols:chevron-right'"
|
||||
size="16"
|
||||
class="text-neutral-400"
|
||||
/>
|
||||
</button>
|
||||
<span v-else class="inline-block w-[22px] flex-shrink-0" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-1 items-center gap-2 text-left min-w-0"
|
||||
@click="handleSelect(folder.path)"
|
||||
>
|
||||
<Icon
|
||||
:name="getFolderIcon(folder.path)"
|
||||
size="16"
|
||||
class="flex-shrink-0"
|
||||
:class="selectedPath === folder.path ? 'text-primary-600' : 'text-neutral-400'"
|
||||
/>
|
||||
|
||||
<span class="flex-1 truncate">
|
||||
{{ getFolderLabel(folder.path, folder.displayName) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="folder.unreadCount > 0"
|
||||
class="ml-auto flex-shrink-0 rounded-full bg-primary-500 px-1.5 py-0.5 text-xs font-bold text-white"
|
||||
>
|
||||
{{ folder.unreadCount > 99 ? '99+' : folder.unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MailFolderTree
|
||||
v-if="hasChildren(folder) && isExpanded(folder.path)"
|
||||
:folders="folder.children"
|
||||
:selected-path="selectedPath"
|
||||
:depth="currentDepth + 1"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,266 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
/** ID BDD du message à lier */
|
||||
messageId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** Émis après liaison réussie — payload = id de la tâche liée */
|
||||
linked: [taskId: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const mailService = useMailService()
|
||||
const taskService = useTaskService()
|
||||
const projectService = useProjectService()
|
||||
|
||||
// ─── État recherche ───────────────────────────────────────────────────────
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterProjectId = ref<number | null>(null)
|
||||
const results = ref<Task[]>([])
|
||||
const selectedTask = ref<Task | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
// ─── Projets pour le filtre ───────────────────────────────────────────────
|
||||
|
||||
const projects = ref<Project[]>([])
|
||||
|
||||
const projectFilterOptions = computed(() =>
|
||||
projects.value.map(p => ({ label: p.name, value: p.id })),
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
projects.value = await projectService.getAll({ archived: false })
|
||||
})
|
||||
|
||||
// ─── Debounce recherche ───────────────────────────────────────────────────
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch([searchQuery, filterProjectId], () => {
|
||||
selectedTask.value = null
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
void runSearch()
|
||||
}, 300)
|
||||
})
|
||||
|
||||
async function runSearch(): Promise<void> {
|
||||
const q = searchQuery.value.trim()
|
||||
if (!q && !filterProjectId.value) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
isLoading.value = true
|
||||
try {
|
||||
const params: Record<string, string | number | boolean | string[]> = {
|
||||
archived: false,
|
||||
}
|
||||
if (q) params['title'] = q
|
||||
if (filterProjectId.value) params['project'] = `/api/projects/${filterProjectId.value}`
|
||||
results.value = await taskService.getFiltered(params)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Reset à l'ouverture ──────────────────────────────────────────────────
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
searchQuery.value = ''
|
||||
filterProjectId.value = null
|
||||
results.value = []
|
||||
selectedTask.value = null
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
})
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function selectTask(task: Task): void {
|
||||
selectedTask.value = task
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!selectedTask.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await mailService.linkTask(props.messageId, selectedTask.value.id)
|
||||
emit('linked', selectedTask.value.id)
|
||||
close()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="mail-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||
@click="close"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="relative z-10 w-full max-w-lg rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden"
|
||||
style="max-height: min(90vh, 640px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
||||
<h2 class="text-base font-bold text-neutral-900">
|
||||
{{ t('mail.linkTaskModal.title') }}
|
||||
</h2>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Corps -->
|
||||
<div class="overflow-y-auto px-6 py-5 space-y-4">
|
||||
<!-- Filtre projet -->
|
||||
<MalioSelect
|
||||
v-model="filterProjectId"
|
||||
:options="projectFilterOptions"
|
||||
:label="t('mail.linkTaskModal.projectFilter')"
|
||||
:empty-option-label="t('mail.linkTaskModal.projectAll')"
|
||||
min-width="w-full"
|
||||
/>
|
||||
|
||||
<!-- Recherche tâche -->
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||
{{ t('mail.linkTaskModal.title') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('mail.linkTaskModal.searchPlaceholder')"
|
||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Résultats -->
|
||||
<div class="max-h-64 overflow-y-auto rounded-md border border-neutral-200">
|
||||
<!-- Chargement -->
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center py-6 text-sm text-neutral-400"
|
||||
>
|
||||
<Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
|
||||
{{ t('mail.linkTaskModal.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div
|
||||
v-else-if="!isLoading && results.length === 0 && (searchQuery.trim() || filterProjectId)"
|
||||
class="py-6 text-center text-sm text-neutral-400 italic"
|
||||
>
|
||||
{{ t('mail.linkTaskModal.empty') }}
|
||||
</div>
|
||||
|
||||
<!-- Liste résultats -->
|
||||
<button
|
||||
v-for="task in results"
|
||||
:key="task.id"
|
||||
type="button"
|
||||
class="flex w-full items-start gap-3 px-4 py-3 text-left text-sm transition-colors hover:bg-neutral-50"
|
||||
:class="selectedTask?.id === task.id
|
||||
? 'bg-primary-50 border-l-2 border-primary-500'
|
||||
: 'border-l-2 border-transparent'"
|
||||
@click="selectTask(task)"
|
||||
>
|
||||
<Icon
|
||||
name="material-symbols:task-outline"
|
||||
size="16"
|
||||
class="mt-0.5 flex-shrink-0 text-neutral-400"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-neutral-800">
|
||||
{{ task.title }}
|
||||
</p>
|
||||
<p
|
||||
v-if="task.project"
|
||||
class="truncate text-xs text-neutral-500"
|
||||
>
|
||||
{{ task.project.name }}
|
||||
<span v-if="task.project.code && task.number">
|
||||
— {{ task.project.code }}-{{ task.number }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="selectedTask?.id === task.id"
|
||||
name="material-symbols:check-circle"
|
||||
size="16"
|
||||
class="flex-shrink-0 text-primary-500"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-end gap-3 border-t border-neutral-100 px-6 py-4">
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="close"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('mail.linkTaskModal.submit')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="!selectedTask || isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mail-modal-enter-active,
|
||||
.mail-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mail-modal-enter-active > div:last-child,
|
||||
.mail-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mail-modal-enter-from,
|
||||
.mail-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mail-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,151 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
messages: readonly MailMessageHeaderDto[]
|
||||
selectedId: number | null
|
||||
loading: boolean
|
||||
hasMore: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [id: number]
|
||||
loadMore: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const sentinelRef = ref<HTMLDivElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (!sentinelRef.value) return
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry?.isIntersecting && props.hasMore && !props.loading) {
|
||||
emit('loadMore')
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
)
|
||||
observer.observe(sentinelRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
})
|
||||
|
||||
/**
|
||||
* Formate une date ISO en date relative (il y a X minutes/heures/jours).
|
||||
* Utilise Intl.RelativeTimeFormat avec la locale fr.
|
||||
*/
|
||||
function formatRelative(isoDate: string | null): string {
|
||||
if (!isoDate) return ''
|
||||
const date = new Date(isoDate)
|
||||
const now = new Date()
|
||||
const diffMs = date.getTime() - now.getTime()
|
||||
const diffSeconds = Math.round(diffMs / 1000)
|
||||
const diffMinutes = Math.round(diffSeconds / 60)
|
||||
const diffHours = Math.round(diffMinutes / 60)
|
||||
const diffDays = Math.round(diffHours / 24)
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' })
|
||||
|
||||
if (Math.abs(diffMinutes) < 1) return rtf.format(diffSeconds, 'second')
|
||||
if (Math.abs(diffHours) < 1) return rtf.format(diffMinutes, 'minute')
|
||||
if (Math.abs(diffDays) < 1) return rtf.format(diffHours, 'hour')
|
||||
if (Math.abs(diffDays) < 30) return rtf.format(diffDays, 'day')
|
||||
|
||||
return date.toLocaleDateString('fr', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function getSenderLabel(msg: MailMessageHeaderDto): string {
|
||||
return msg.fromName ?? msg.fromEmail ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="!loading && messages.length === 0"
|
||||
class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-4 text-center"
|
||||
>
|
||||
{{ t('mail.empty.list') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 overflow-y-auto divide-y divide-neutral-100">
|
||||
<button
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
type="button"
|
||||
class="flex w-full gap-3 px-3 py-3 text-left transition-colors hover:bg-neutral-50 focus:outline-none"
|
||||
:class="[
|
||||
selectedId === msg.id ? 'bg-primary-50 border-l-2 border-primary-500' : '',
|
||||
!msg.isRead ? 'bg-white' : 'bg-neutral-50/50',
|
||||
]"
|
||||
@click="emit('select', msg.id)"
|
||||
>
|
||||
<div class="mt-1.5 flex-shrink-0">
|
||||
<span
|
||||
class="block h-2 w-2 rounded-full"
|
||||
:class="msg.isRead ? 'bg-transparent' : 'bg-primary-500'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span
|
||||
class="truncate text-sm"
|
||||
:class="msg.isRead ? 'text-neutral-600 font-normal' : 'text-neutral-900 font-semibold'"
|
||||
>
|
||||
{{ getSenderLabel(msg) }}
|
||||
</span>
|
||||
<span class="flex-shrink-0 text-xs text-neutral-400">
|
||||
{{ formatRelative(msg.sentAt ?? msg.receivedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="truncate text-sm"
|
||||
:class="msg.isRead ? 'text-neutral-500' : 'text-neutral-800 font-medium'"
|
||||
>
|
||||
{{ msg.subject ?? t('mail.noSubject') }}
|
||||
</p>
|
||||
|
||||
<div class="mt-0.5 flex items-center gap-1.5">
|
||||
<Icon
|
||||
v-if="msg.isFlagged"
|
||||
name="material-symbols:star"
|
||||
size="14"
|
||||
class="text-amber-400 flex-shrink-0"
|
||||
/>
|
||||
<Icon
|
||||
v-if="msg.hasAttachments"
|
||||
name="material-symbols:attach-file"
|
||||
size="14"
|
||||
class="text-neutral-400 flex-shrink-0"
|
||||
/>
|
||||
<Icon
|
||||
v-if="msg.linkedTaskIds.length > 0"
|
||||
name="material-symbols:task-outline"
|
||||
size="14"
|
||||
class="text-primary-400 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div ref="sentinelRef" class="h-px" />
|
||||
|
||||
<div v-if="loading && messages.length > 0" class="flex items-center justify-center py-4">
|
||||
<Icon name="material-symbols:progress-activity" size="20" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && messages.length === 0" class="flex flex-1 items-center justify-center">
|
||||
<Icon name="material-symbols:progress-activity" size="24" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,183 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MailMessageDetailDto, MailAddressDto } from '~/services/dto/mail'
|
||||
import { sanitizeMailHtml } from '~/utils/sanitizeMailHtml'
|
||||
import { useMailService } from '~/services/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Détail complet du message. null = aucun message sélectionné. */
|
||||
detail: MailMessageDetailDto | null
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
createTask: [mailId: number]
|
||||
linkTask: [mailId: number]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const mailService = useMailService()
|
||||
|
||||
const showImages = ref(false)
|
||||
|
||||
const sanitizedBody = computed((): string => {
|
||||
if (!props.detail?.bodyHtml) return ''
|
||||
return sanitizeMailHtml(props.detail.bodyHtml, { allowImages: showImages.value })
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.detail?.header.id,
|
||||
() => {
|
||||
showImages.value = false
|
||||
},
|
||||
)
|
||||
|
||||
async function handleDownload(downloadId: string, filename: string): Promise<void> {
|
||||
try {
|
||||
const { data } = await mailService.downloadAttachment(downloadId)
|
||||
const url = URL.createObjectURL(data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
// L'erreur est gérée par useApi (toast automatique)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('fr', {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function joinAddresses(addresses: MailAddressDto[]): string {
|
||||
return addresses
|
||||
.map((a) => (a.name ? `${a.name} <${a.email}>` : a.email))
|
||||
.join(', ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div
|
||||
v-if="!detail && !loading"
|
||||
class="flex flex-1 items-center justify-center text-sm text-neutral-400 italic px-8 text-center"
|
||||
>
|
||||
{{ t('mail.empty.viewer') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="flex flex-1 items-center justify-center">
|
||||
<Icon name="material-symbols:progress-activity" size="28" class="animate-spin text-neutral-400" />
|
||||
</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<div class="flex-shrink-0 border-b border-neutral-200 px-4 py-3 space-y-1.5">
|
||||
<h2 class="text-base font-semibold text-neutral-900 break-words">
|
||||
{{ detail.header.subject ?? t('mail.noSubject') }}
|
||||
</h2>
|
||||
|
||||
<dl class="text-xs text-neutral-500 space-y-0.5">
|
||||
<div class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.from') }}</dt>
|
||||
<dd class="break-all">
|
||||
{{
|
||||
detail.header.fromName
|
||||
? `${detail.header.fromName} <${detail.header.fromEmail}>`
|
||||
: (detail.header.fromEmail ?? '')
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="detail.header.toRecipients.length > 0" class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.to') }}</dt>
|
||||
<dd class="break-all">{{ joinAddresses(detail.header.toRecipients) }}</dd>
|
||||
</div>
|
||||
<div v-if="detail.header.ccRecipients.length > 0" class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.cc') }}</dt>
|
||||
<dd class="break-all">{{ joinAddresses(detail.header.ccRecipients) }}</dd>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
<dt class="font-medium text-neutral-600 w-5 flex-shrink-0">{{ t('mail.date') }}</dt>
|
||||
<dd>{{ formatDate(detail.header.sentAt ?? detail.header.receivedAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 pt-1">
|
||||
<MalioButton
|
||||
:label="t('mail.actions.createTask')"
|
||||
variant="primary"
|
||||
icon-name="material-symbols:add-task-outline"
|
||||
icon-position="left"
|
||||
:icon-size="13"
|
||||
button-class="text-xs px-2.5 py-1"
|
||||
@click="emit('createTask', detail.header.id)"
|
||||
/>
|
||||
<MalioButton
|
||||
:label="t('mail.actions.linkTask')"
|
||||
variant="secondary"
|
||||
icon-name="material-symbols:link"
|
||||
icon-position="left"
|
||||
:icon-size="13"
|
||||
button-class="text-xs px-2.5 py-1"
|
||||
@click="emit('linkTask', detail.header.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 py-3">
|
||||
<div
|
||||
v-if="!showImages && detail.bodyHtml"
|
||||
class="mb-3 flex items-center gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm"
|
||||
>
|
||||
<Icon name="material-symbols:image-outline" size="16" class="text-amber-500 flex-shrink-0" />
|
||||
<span class="flex-1 text-amber-700">
|
||||
{{ t('mail.remoteImagesBlocked') }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-amber-700 underline hover:text-amber-900 transition-colors"
|
||||
@click="showImages = true"
|
||||
>
|
||||
{{ t('mail.actions.showImages') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="detail.bodyHtml"
|
||||
class="prose prose-sm max-w-none text-neutral-800"
|
||||
v-html="sanitizedBody"
|
||||
/>
|
||||
|
||||
<pre
|
||||
v-else-if="detail.bodyText"
|
||||
class="whitespace-pre-wrap font-sans text-sm text-neutral-700"
|
||||
>{{ detail.bodyText }}</pre>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="detail.attachments.length > 0"
|
||||
class="flex-shrink-0 border-t border-neutral-200 px-4 py-3"
|
||||
>
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||
{{ t('mail.attachments') }} ({{ detail.attachments.length }})
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="att in detail.attachments"
|
||||
:key="att.downloadId"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded border border-neutral-200 bg-neutral-50 px-2.5 py-1.5 text-xs text-neutral-700 transition-colors hover:bg-neutral-100 hover:border-neutral-300"
|
||||
:title="att.filename"
|
||||
@click="handleDownload(att.downloadId, att.filename)"
|
||||
>
|
||||
<Icon name="material-symbols:attach-file" size="14" class="flex-shrink-0 text-neutral-400" />
|
||||
<span class="max-w-[180px] truncate">{{ att.filename }}</span>
|
||||
<span class="text-neutral-400">({{ Math.round(att.size / 1024) }} Ko)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,24 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
|
||||
const store = useMailStore()
|
||||
const { syncing } = storeToRefs(store)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
async function handleRefresh(): Promise<void> {
|
||||
await store.triggerSync()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MalioButton
|
||||
:label="t('mail.actions.refresh')"
|
||||
variant="secondary"
|
||||
icon-name="material-symbols:refresh"
|
||||
icon-position="left"
|
||||
:icon-size="16"
|
||||
:disabled="syncing"
|
||||
@click="handleRefresh"
|
||||
/>
|
||||
</template>
|
||||
@@ -2,13 +2,13 @@
|
||||
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('projects.editProject') : $t('projects.addProject')">
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="codeProxy"
|
||||
v-model="form.code"
|
||||
label="Code"
|
||||
input-class="w-full"
|
||||
:max-length="10"
|
||||
input-class="w-full uppercase"
|
||||
:disabled="isEditing"
|
||||
:error="touched.code && !form.code ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code) ? '2 à 10 lettres majuscules' : ''"
|
||||
:error="touched.code && !form.code.trim() ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code.trim()) ? '2 à 10 lettres majuscules' : ''"
|
||||
@blur="touched.code = true"
|
||||
@input="form.code = form.code.toUpperCase().replace(/[^A-Z]/g, '')"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
@@ -186,17 +186,6 @@ const touched = reactive({
|
||||
name: false,
|
||||
})
|
||||
|
||||
// Source unique de vérité : on sanitise dans le setter (majuscules, lettres
|
||||
// uniquement, max 10) plutôt que via @input — sinon course entre la mutation
|
||||
// manuelle et l'émission update:modelValue de MalioInputText, qui laissait
|
||||
// form.code en minuscules et bloquait la création.
|
||||
const codeProxy = computed({
|
||||
get: () => form.code,
|
||||
set: (value: string) => {
|
||||
form.code = (value ?? '').toUpperCase().replace(/[^A-Z]/g, '').slice(0, 10)
|
||||
},
|
||||
})
|
||||
|
||||
const clientOptions = computed(() =>
|
||||
props.clients.map(c => ({ label: c.name, value: c.id }))
|
||||
)
|
||||
@@ -233,7 +222,7 @@ async function handleSubmit() {
|
||||
touched.name = true
|
||||
touched.code = true
|
||||
if (!form.name.trim()) return
|
||||
if (!isEditing.value && !/^[A-Z]{2,10}$/.test(form.code)) return
|
||||
if (!isEditing.value && (!form.code.trim() || !/^[A-Z]{2,10}$/.test(form.code.trim()))) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
@@ -265,7 +254,7 @@ async function handleSubmit() {
|
||||
if (isEditing.value && props.project) {
|
||||
await update(props.project.id, payload)
|
||||
} else {
|
||||
payload.code = form.code
|
||||
payload.code = form.code.trim()
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TaskStatus } from '~/services/dto/task-status'
|
||||
|
||||
defineProps<{
|
||||
statuses: TaskStatus[]
|
||||
x: number
|
||||
y: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
pick: [status: TaskStatus]
|
||||
cancel: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 z-[60]" @click="emit('cancel')" />
|
||||
<div
|
||||
class="fixed z-[61] min-w-44 rounded-lg border border-neutral-200 bg-white py-1 shadow-xl"
|
||||
:style="{ left: x + 'px', top: y + 'px' }"
|
||||
>
|
||||
<button
|
||||
v-for="s in statuses"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
|
||||
@click="emit('pick', s)"
|
||||
>
|
||||
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: s.color }" />
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -27,7 +27,7 @@
|
||||
:title="$t('clientTicket.linkedTooltip', { number: 'CT-' + String(task.clientTicket.number).padStart(3, '0') })"
|
||||
/>
|
||||
</div>
|
||||
<h4 class="line-clamp-2 text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
<h4 class="text-sm font-semibold text-neutral-900">{{ task.title }}</h4>
|
||||
</div>
|
||||
<MalioButtonIcon
|
||||
:icon="isTimerOnTask ? 'mdi:stop-circle-outline' : 'mdi:play-circle-outline'"
|
||||
@@ -39,7 +39,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<div class="mt-2 flex items-center gap-1.5">
|
||||
<span
|
||||
v-if="showStatusBadge && task.status"
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||
|
||||
@@ -55,21 +55,21 @@
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<form @submit.prevent="handleSubmit" class="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||
<form @submit.prevent="handleSubmit" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||
<!-- Tabs -->
|
||||
<div class="border-b border-neutral-100 -mx-4 px-4 sm:-mx-8 sm:px-8 mb-4">
|
||||
<nav class="flex gap-6">
|
||||
<button
|
||||
v-for="tab in availableTabs"
|
||||
v-for="tab in ['details', 'planning']"
|
||||
:key="tab"
|
||||
type="button"
|
||||
class="px-1 pb-3 text-sm font-semibold transition"
|
||||
:class="activeTab === tab
|
||||
? 'border-b-2 border-primary-500 text-primary-500'
|
||||
: 'text-neutral-500 hover:text-neutral-700'"
|
||||
@click="activeTab = tab as 'details' | 'planning' | 'mails'"
|
||||
@click="activeTab = tab as 'details' | 'planning'"
|
||||
>
|
||||
{{ tab === 'mails' ? $t('mail.taskTab.title') : $t(`tasks.${tab}Tab`) }}
|
||||
{{ $t(`tasks.${tab}Tab`) }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -433,103 +433,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Onglet Mails -->
|
||||
<div v-show="activeTab === 'mails'" class="space-y-4">
|
||||
<!-- Chargement -->
|
||||
<div v-if="mailsLoading" class="flex items-center justify-center py-8">
|
||||
<Icon name="material-symbols:progress-activity" size="24" class="animate-spin text-neutral-400" />
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
|
||||
:class="isEditing ? 'justify-between' : 'justify-end'"
|
||||
>
|
||||
<MalioButton
|
||||
v-if="isEditing"
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
/>
|
||||
<div class="flex gap-3">
|
||||
<MalioButton
|
||||
v-if="canArchive"
|
||||
variant="tertiary"
|
||||
:label="$t('archive.archiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchive"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canUnarchive"
|
||||
variant="tertiary"
|
||||
:label="$t('archive.unarchiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleUnarchive"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="close"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div
|
||||
v-else-if="linkedMails.length === 0"
|
||||
class="flex flex-col items-center justify-center gap-3 py-8 text-center"
|
||||
>
|
||||
<Icon name="material-symbols:mail-outline" size="32" class="text-neutral-300" />
|
||||
<p class="text-sm text-neutral-400 italic">{{ $t('mail.taskTab.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Liste mails liés -->
|
||||
<div v-else class="divide-y divide-neutral-100 rounded-lg border border-neutral-200">
|
||||
<NuxtLink
|
||||
v-for="mail in linkedMails"
|
||||
:key="mail.id"
|
||||
:to="`/mail?messageId=${mail.id}`"
|
||||
class="flex items-start gap-3 px-4 py-3 text-sm transition-colors hover:bg-neutral-50"
|
||||
:title="$t('mail.taskTab.openInMailer')"
|
||||
>
|
||||
<Icon
|
||||
name="material-symbols:mail-outline"
|
||||
size="16"
|
||||
class="mt-0.5 flex-shrink-0 text-neutral-400"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-neutral-800">
|
||||
{{ mail.subject ?? $t('mail.noSubject') }}
|
||||
</p>
|
||||
<p class="flex items-center gap-2 text-xs text-neutral-500">
|
||||
<span class="truncate">{{ mail.fromName ?? mail.fromEmail }}</span>
|
||||
<span>·</span>
|
||||
<span class="flex-shrink-0">{{ formatMailDate(mail.sentAt ?? mail.receivedAt) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Icon
|
||||
name="material-symbols:open-in-new"
|
||||
size="14"
|
||||
class="flex-shrink-0 text-neutral-300"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<!-- Footer -->
|
||||
<div
|
||||
class="shrink-0 flex items-center border-t border-neutral-100 bg-white px-4 py-4 sm:px-8 sm:py-5"
|
||||
:class="isEditing ? 'justify-between' : 'justify-end'"
|
||||
>
|
||||
<MalioButton
|
||||
v-if="isEditing"
|
||||
variant="danger"
|
||||
label="Supprimer"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="confirmDeleteOpen = true"
|
||||
/>
|
||||
<div class="flex gap-3">
|
||||
<MalioButton
|
||||
v-if="canArchive"
|
||||
variant="tertiary"
|
||||
:label="$t('archive.archiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleArchive"
|
||||
/>
|
||||
<MalioButton
|
||||
v-if="canUnarchive"
|
||||
variant="tertiary"
|
||||
:label="$t('archive.unarchiveButton')"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleUnarchive"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="tertiary"
|
||||
label="Annuler"
|
||||
button-class="w-auto px-4"
|
||||
@click="close"
|
||||
/>
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDeleteTaskModal
|
||||
v-model="confirmDeleteOpen"
|
||||
@confirm="handleDelete"
|
||||
@@ -564,8 +513,6 @@ import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskRecurrenceService } from '~/services/task-recurrences'
|
||||
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import { useMailService } from '~/services/mail'
|
||||
import type { MailMessageHeaderDto } from '~/services/dto/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -598,13 +545,7 @@ function close() {
|
||||
const isEditing = computed(() => !!props.task)
|
||||
const isSubmitting = ref(false)
|
||||
const confirmDeleteOpen = ref(false)
|
||||
const activeTab = ref<'details' | 'planning' | 'mails'>('details')
|
||||
|
||||
// ─── Onglet Mails ─────────────────────────────────────────────────────────
|
||||
|
||||
const mailService = useMailService()
|
||||
const linkedMails = ref<MailMessageHeaderDto[]>([])
|
||||
const mailsLoading = ref(false)
|
||||
const activeTab = ref<'details' | 'planning'>('details')
|
||||
|
||||
const giteaUrl = ref('')
|
||||
const { getSettings: getGiteaSettings } = useGiteaService()
|
||||
@@ -651,27 +592,10 @@ const touched = reactive({
|
||||
project: false,
|
||||
})
|
||||
|
||||
const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
|
||||
const statusOptions = computed(() =>
|
||||
props.statuses.map(s => ({ label: s.label, value: s.id }))
|
||||
)
|
||||
|
||||
const resolvedProjectId = computed(() =>
|
||||
showProjectSelect.value ? form.projectId : props.projectId
|
||||
)
|
||||
|
||||
const statusOptions = computed(() => {
|
||||
const project = props.projects?.find(p => p.id === resolvedProjectId.value)
|
||||
const wfStatuses = project?.workflow?.statuses ?? props.statuses
|
||||
const opts = wfStatuses.map(s => ({ label: s.label, value: s.id }))
|
||||
const current = props.task?.status
|
||||
if (current && !wfStatuses.some(s => s.id === current.id)) {
|
||||
opts.unshift({ label: current.label, value: current.id })
|
||||
}
|
||||
return opts
|
||||
})
|
||||
|
||||
const effortOptions = computed(() =>
|
||||
props.efforts.map(e => ({ label: e.label, value: e.id }))
|
||||
)
|
||||
@@ -704,6 +628,16 @@ const groupOptions = computed(() => {
|
||||
return filtered.map(g => ({ label: g.title, value: g.id }))
|
||||
})
|
||||
|
||||
const showProjectSelect = computed(() => !!props.projects?.length && !isEditing.value)
|
||||
|
||||
const projectOptions = computed(() =>
|
||||
(props.projects ?? []).map(p => ({ label: p.name, value: p.id }))
|
||||
)
|
||||
|
||||
const resolvedProjectId = computed(() =>
|
||||
showProjectSelect.value ? form.projectId : props.projectId
|
||||
)
|
||||
|
||||
const canArchive = computed(() => {
|
||||
if (!isEditing.value || !props.task) return false
|
||||
if (props.task.archived) return false
|
||||
@@ -831,7 +765,6 @@ watch(() => props.modelValue, async (open) => {
|
||||
activeTab.value = 'details'
|
||||
confirmDeleteDocOpen.value = false
|
||||
documentToDelete.value = null
|
||||
linkedMails.value = []
|
||||
populateForm(props.task)
|
||||
const pid = resolvedProjectId.value
|
||||
if (pid) {
|
||||
@@ -890,44 +823,6 @@ watch(() => form.projectId, async (pid) => {
|
||||
const authStore = useAuthStore()
|
||||
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
authStore.user?.roles?.includes('ROLE_CLIENT') === true
|
||||
&& authStore.user?.roles?.includes('ROLE_ADMIN') !== true,
|
||||
)
|
||||
const isMailUser = computed(() => !isClientOnly.value)
|
||||
|
||||
const availableTabs = computed(() => {
|
||||
const base: Array<'details' | 'planning' | 'mails'> = ['details', 'planning']
|
||||
if (isEditing.value && isMailUser.value) base.push('mails')
|
||||
return base
|
||||
})
|
||||
|
||||
async function loadLinkedMails(): Promise<void> {
|
||||
if (!props.task || !isMailUser.value) return
|
||||
mailsLoading.value = true
|
||||
try {
|
||||
linkedMails.value = await mailService.listMailsForTask(props.task.id)
|
||||
} catch {
|
||||
linkedMails.value = []
|
||||
} finally {
|
||||
mailsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(activeTab, async (tab) => {
|
||||
if (tab === 'mails' && props.task) {
|
||||
await loadLinkedMails()
|
||||
}
|
||||
})
|
||||
|
||||
function formatMailDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString('fr', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function ticketStatusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'new': return 'bg-blue-100 text-blue-700'
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
/** Largeur max du panneau */
|
||||
width?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}>(), {
|
||||
title: '',
|
||||
width: 'md',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const WIDTH_CLASS: Record<NonNullable<typeof props.width>, string> = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="app-modal" appear>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="close" />
|
||||
|
||||
<div
|
||||
class="relative z-10 flex max-h-[90vh] w-full flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||
:class="WIDTH_CLASS[width]"
|
||||
>
|
||||
<!-- Header (fixe) -->
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-neutral-100 bg-neutral-50/80 px-6 py-4">
|
||||
<h2 class="text-base font-bold text-neutral-900">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</h2>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:close"
|
||||
aria-label="Fermer"
|
||||
variant="ghost"
|
||||
icon-size="20"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Body (scrollable) -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer (sticky) -->
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="flex shrink-0 justify-end gap-3 border-t border-neutral-100 bg-white px-6 py-4"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-modal-enter-active,
|
||||
.app-modal-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.app-modal-enter-active > div:last-child,
|
||||
.app-modal-leave-active > div:last-child {
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
||||
}
|
||||
.app-modal-enter-from,
|
||||
.app-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.app-modal-enter-from > div:last-child {
|
||||
transform: scale(0.95) translateY(8px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -13,14 +13,6 @@
|
||||
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||
<MalioButtonIcon
|
||||
icon="mdi:help-circle-outline"
|
||||
aria-label="Centre d'aide"
|
||||
variant="ghost"
|
||||
icon-size="22"
|
||||
button-class="text-white/70 hover:bg-primary-600 hover:text-white"
|
||||
@click="navigateTo('/help')"
|
||||
/>
|
||||
<MalioButtonIcon
|
||||
:icon="ui.darkMode ? 'mdi:weather-sunny' : 'mdi:weather-night'"
|
||||
:aria-label="ui.darkMode ? 'Mode clair' : 'Mode sombre'"
|
||||
|
||||
@@ -1,45 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">Couleur</p>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button
|
||||
v-for="color in presets"
|
||||
v-for="color in colors"
|
||||
:key="color"
|
||||
type="button"
|
||||
class="h-10 w-10 rounded-full border-2 transition-transform hover:scale-110"
|
||||
:class="isSelected(color) ? 'border-neutral-900 scale-110' : 'border-transparent'"
|
||||
:class="modelValue === color ? 'border-neutral-900 scale-110' : 'border-transparent'"
|
||||
:style="{ backgroundColor: color }"
|
||||
:aria-label="`Choisir la couleur ${color}`"
|
||||
@click="select(color)"
|
||||
@click="emit('update:modelValue', color)"
|
||||
/>
|
||||
|
||||
<!-- Couleur personnalisée : input natif déguisé en pastille -->
|
||||
<label
|
||||
class="relative flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border-2 transition-transform hover:scale-110"
|
||||
:class="isCustom ? 'border-neutral-900 scale-110' : 'border-dashed border-neutral-400'"
|
||||
:style="isCustom ? { backgroundColor: modelValue } : {}"
|
||||
title="Couleur personnalisée"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
class="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
:value="modelValue"
|
||||
aria-label="Choisir une couleur personnalisée"
|
||||
@input="select(($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
<Icon
|
||||
v-if="!isCustom"
|
||||
name="mdi:plus"
|
||||
class="text-neutral-500"
|
||||
size="20"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
|
||||
@@ -47,26 +24,8 @@ const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
// Les 9 premières sont historiques (couleurs déjà en base) — ne pas réordonner
|
||||
// pour que les projets/tags existants restent associés à une pastille.
|
||||
const presets = [
|
||||
'#222783', '#26A69A', '#E91E63', '#4A90D9', '#7E57C2',
|
||||
'#8BC34A', '#FDD835', '#80DEEA', '#FF7043', '#EF4444',
|
||||
'#F97316', '#F59E0B', '#22C55E', '#10B981', '#06B6D4',
|
||||
'#3B82F6', '#8B5CF6', '#64748B',
|
||||
const colors = [
|
||||
'#222783', '#26A69A', '#E91E63', '#4A90D9',
|
||||
'#7E57C2', '#8BC34A', '#FDD835', '#80DEEA', '#FF7043',
|
||||
]
|
||||
|
||||
const norm = (value: string): string => (value ?? '').toUpperCase()
|
||||
|
||||
function isSelected(color: string): boolean {
|
||||
return norm(props.modelValue) === norm(color)
|
||||
}
|
||||
|
||||
const isCustom = computed(
|
||||
() => !!props.modelValue && !presets.some((c) => norm(c) === norm(props.modelValue)),
|
||||
)
|
||||
|
||||
function select(value: string): void {
|
||||
emit('update:modelValue', norm(value))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Mapping des chemins de dossiers système IMAP vers les clés i18n.
|
||||
* Les clés sont normalisées en minuscules pour la comparaison.
|
||||
* Couvre les variantes OVH courantes (INBOX, INBOX.Sent, Sent, etc.)
|
||||
*/
|
||||
const SYSTEM_FOLDER_MAP: Record<string, string> = {
|
||||
'inbox': 'mail.systemFolder.inbox',
|
||||
'sent': 'mail.systemFolder.sent',
|
||||
'inbox.sent': 'mail.systemFolder.sent',
|
||||
'sent messages': 'mail.systemFolder.sent',
|
||||
'drafts': 'mail.systemFolder.drafts',
|
||||
'inbox.drafts': 'mail.systemFolder.drafts',
|
||||
'archive': 'mail.systemFolder.archive',
|
||||
'archives': 'mail.systemFolder.archive',
|
||||
'inbox.archive': 'mail.systemFolder.archive',
|
||||
'trash': 'mail.systemFolder.trash',
|
||||
'deleted': 'mail.systemFolder.trash',
|
||||
'deleted items': 'mail.systemFolder.trash',
|
||||
'inbox.trash': 'mail.systemFolder.trash',
|
||||
'junk': 'mail.systemFolder.junk',
|
||||
'junk e-mail': 'mail.systemFolder.junk',
|
||||
'spam': 'mail.systemFolder.junk',
|
||||
'inbox.junk': 'mail.systemFolder.junk',
|
||||
}
|
||||
|
||||
/**
|
||||
* Icônes Material Symbols associées aux dossiers système.
|
||||
* Pour les dossiers non reconnus : utiliser une icône générique.
|
||||
*/
|
||||
const SYSTEM_FOLDER_ICONS: Record<string, string> = {
|
||||
'mail.systemFolder.inbox': 'material-symbols:inbox-outline',
|
||||
'mail.systemFolder.sent': 'material-symbols:send-outline',
|
||||
'mail.systemFolder.drafts': 'material-symbols:draft-outline',
|
||||
'mail.systemFolder.archive': 'material-symbols:archive-outline',
|
||||
'mail.systemFolder.trash': 'material-symbols:delete-outline',
|
||||
'mail.systemFolder.junk': 'material-symbols:report-outline',
|
||||
}
|
||||
|
||||
const DEFAULT_FOLDER_ICON = 'material-symbols:folder-outline'
|
||||
|
||||
export function useSystemFolderLabel() {
|
||||
const { t } = useI18n()
|
||||
|
||||
/**
|
||||
* Retourne le label traduit d'un dossier système, ou son displayName si inconnu.
|
||||
* @param path - Chemin IMAP du dossier (ex: "INBOX", "INBOX.Sent")
|
||||
* @param displayName - Nom affiché par défaut si non reconnu
|
||||
*/
|
||||
function getFolderLabel(path: string, displayName: string): string {
|
||||
const key = SYSTEM_FOLDER_MAP[path.toLowerCase()]
|
||||
return key ? t(key) : displayName
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le nom de l'icône Material Symbols pour un dossier.
|
||||
* @param path - Chemin IMAP du dossier
|
||||
*/
|
||||
function getFolderIcon(path: string): string {
|
||||
const key = SYSTEM_FOLDER_MAP[path.toLowerCase()]
|
||||
return key ? (SYSTEM_FOLDER_ICONS[key] ?? DEFAULT_FOLDER_ICON) : DEFAULT_FOLDER_ICON
|
||||
}
|
||||
|
||||
/**
|
||||
* Indique si un dossier est un dossier système reconnu.
|
||||
*/
|
||||
function isSystemFolder(path: string): boolean {
|
||||
return path.toLowerCase() in SYSTEM_FOLDER_MAP
|
||||
}
|
||||
|
||||
return {
|
||||
getFolderLabel,
|
||||
getFolderIcon,
|
||||
isSystemFolder,
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
# Bienvenue dans Lesstime
|
||||
|
||||
Lesstime est un outil de **gestion de projets** qui combine 4 grandes capacités :
|
||||
|
||||
- 🗂️ **Gestion de projets** avec kanban personnalisable (workflows)
|
||||
- ✅ **Suivi de tâches** avec assignations, priorités, efforts, deadlines, tags
|
||||
- ⏱️ **Time tracking** intégré, lié aux projets et aux tâches
|
||||
- 🎫 **Portail client** pour que tes clients déposent leurs tickets
|
||||
|
||||
## Comprendre les rôles
|
||||
|
||||
| Rôle | Accès |
|
||||
|---|---|
|
||||
| **Admin** | Tout : projets, utilisateurs, intégrations, workflows |
|
||||
| **User** | Ses tâches, time tracking, projets auxquels il a accès |
|
||||
| **Client** | Portal dédié — tickets sur ses projets uniquement |
|
||||
|
||||
## Vues principales
|
||||
|
||||
- **Dashboard** : vue d'ensemble personnelle (KPIs, tâches du jour)
|
||||
- **Mes tâches** : kanban perso groupé par catégorie, toutes projets confondus
|
||||
- **Projets** : un kanban par projet, statuts du workflow associé
|
||||
- **Time tracking** : timer, time entries, vue mois
|
||||
- **Admin** : gestion globale (visible uniquement par les admins)
|
||||
- **Portal** : interface dédiée aux utilisateurs ROLE_CLIENT
|
||||
|
||||
> 💡 **Astuce** : utilise l'avatar en haut à droite pour accéder à ton profil et y générer un **token MCP** (cf. section *Token MCP & API*) pour piloter Lesstime depuis Claude / Cursor.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Projets & Workflows
|
||||
|
||||
## Qu'est-ce qu'un projet ?
|
||||
|
||||
Un projet regroupe un ensemble de **tâches**, **time entries** et éventuellement **tickets client**. Il est défini par :
|
||||
|
||||
- Un **code court** (2-10 lettres majuscules, ex: `SIRH`, `CRM`) qui préfixe les numéros de tâches
|
||||
- Un **client** optionnel (ou interne si null)
|
||||
- Une **couleur** d'identification
|
||||
- Un **workflow** (obligatoire) qui définit ses colonnes kanban
|
||||
|
||||
## Qu'est-ce qu'un workflow ?
|
||||
|
||||
Un **workflow** est un *jeu de statuts kanban* réutilisable. Au lieu d'avoir une liste globale de statuts comme dans la plupart des outils, chaque projet a son propre kanban adapté à sa façon de travailler.
|
||||
|
||||
### Exemple
|
||||
|
||||
| Workflow | Statuts |
|
||||
|---|---|
|
||||
| **Standard** (par défaut) | À faire → En cours → Bloqué → En attente de validation → Terminé |
|
||||
| **DevKanban** | Backlog → Spec → In Dev → Review PR → QA → Done |
|
||||
| **Support** | Nouveau → Diagnostic → Résolu |
|
||||
|
||||
Tu peux créer autant de workflows que tu veux depuis **Admin → Workflows**.
|
||||
|
||||
## Les 5 catégories canoniques
|
||||
|
||||
Chaque statut, peu importe son workflow, appartient à **une catégorie canonique** parmi :
|
||||
|
||||
| Catégorie | Description |
|
||||
|---|---|
|
||||
| `todo` | À faire — pas encore commencé |
|
||||
| `in_progress` | En cours — quelqu'un bosse dessus |
|
||||
| `blocked` | Bloqué — attente d'une dépendance |
|
||||
| `review` | En validation — relecture, PR, QA |
|
||||
| `done` | Terminé — close |
|
||||
|
||||
> 🎯 **Pourquoi des catégories ?** Pour que la vue *Mes tâches* puisse regrouper des tâches venant de projets avec des workflows différents (ex: une tâche "In Dev" de DevKanban et "En cours" de Standard apparaissent dans la même colonne `in_progress`).
|
||||
|
||||
## Changer le workflow d'un projet
|
||||
|
||||
1. Ouvrir le projet → **Modifier le projet** (drawer)
|
||||
2. Section **Workflow** → cliquer sur **Changer de workflow**
|
||||
3. Sélectionner le workflow cible
|
||||
4. **Mapper chaque statut source vers un statut cible** (le mapping est pré-rempli automatiquement par catégorie)
|
||||
5. **Confirmer** — toutes les tâches migrent dans une seule transaction
|
||||
|
||||
### Règles du mapping
|
||||
|
||||
- ✅ Chaque statut actuellement utilisé par une tâche **doit** être mappé (sinon erreur 422)
|
||||
- ✅ Un statut peut être mappé vers `null` → la tâche passe en backlog (sans statut)
|
||||
- ❌ Tu ne peux pas mapper vers un statut qui n'appartient pas au workflow cible
|
||||
|
||||
## Supprimer un workflow
|
||||
|
||||
Tu peux supprimer un workflow uniquement s'il n'est **lié à aucun projet** (HTTP 409 sinon). Réassigne d'abord les projets vers un autre workflow.
|
||||
|
||||
> ⚠️ Le workflow **Standard** ne peut pas être supprimé tant qu'il reste le défaut (un seul workflow peut avoir `isDefault=true` à la fois, garanti par un listener Doctrine).
|
||||
@@ -1,60 +0,0 @@
|
||||
# Mes tâches & Dashboard
|
||||
|
||||
## Vue *Mes tâches*
|
||||
|
||||
Accessible via la sidebar, c'est ta vue **transverse** : toutes les tâches dont tu es l'**assigné** ou un **collaborateur**, peu importe le projet.
|
||||
|
||||
### Deux modes d'affichage
|
||||
|
||||
#### 1. Kanban (par défaut)
|
||||
|
||||
Regroupé par les **5 catégories canoniques** :
|
||||
|
||||
```
|
||||
À faire → En cours → Bloqué → En validation → Terminé
|
||||
```
|
||||
|
||||
Chaque card affiche :
|
||||
- Le **code projet + numéro** (ex: `SIRH-12`) coloré selon le projet
|
||||
- Un **badge statut** (utile quand des tâches de projets différents cohabitent)
|
||||
- Priorité, tags, deadline, icônes (sync calendrier, récurrence, collaborateurs)
|
||||
- L'**avatar de l'assigné** + bouton timer (▶ / ⏹)
|
||||
|
||||
> 💡 Le **drag-to-status** est intentionnellement désactivé dans *Mes tâches* — pour changer un statut, ouvre la tâche (la valeur dépend du workflow du projet, pas de la catégorie).
|
||||
|
||||
#### 2. Liste
|
||||
|
||||
Vue tableau triable, avec **bulk actions** :
|
||||
- Cocher plusieurs tâches → barre d'actions en haut
|
||||
- Changer statut (désactivé si tâches de **projets différents**), assigné, priorité, effort, groupe
|
||||
- Supprimer en lot
|
||||
|
||||
### Filtres disponibles
|
||||
|
||||
| Filtre | Notes |
|
||||
|---|---|
|
||||
| **Projet** | Restreint à un projet précis |
|
||||
| **Groupe** | Disponible uniquement si un projet est sélectionné |
|
||||
| **Tag** | Tags globaux |
|
||||
| **Priorité / Effort** | |
|
||||
| **Assigné** | Par défaut : toi-même |
|
||||
|
||||
### Tri (vue liste uniquement)
|
||||
|
||||
- Par **deadline** (les plus proches en premier)
|
||||
- Par **scheduled start** (planification calendrier)
|
||||
|
||||
## Vue *Backlog*
|
||||
|
||||
Sous le kanban, les tâches **sans statut** apparaissent dans la section *Backlog*. Pratique pour les idées non encore qualifiées.
|
||||
|
||||
## Dashboard
|
||||
|
||||
Le **dashboard** (page d'accueil après login) affiche :
|
||||
|
||||
- 📊 **KPIs personnels** : tâches en cours / à faire / en retard
|
||||
- 📈 **Charts** : répartition par statut, par priorité, time tracking cette semaine
|
||||
- 🔔 **Notifications** : assignations, commentaires (cf. cloche en topbar)
|
||||
- ⏱ **Timer actif** s'il y en a un
|
||||
|
||||
> 💡 Tu peux changer le filtre user du dashboard via le sélecteur en haut pour voir les KPIs d'un collègue (utile pour les leads).
|
||||
@@ -1,59 +0,0 @@
|
||||
# Time tracking
|
||||
|
||||
## Le timer
|
||||
|
||||
Le timer **flottant** est accessible depuis la sidebar ou directement depuis une tâche.
|
||||
|
||||
### Démarrer un timer
|
||||
|
||||
Trois façons :
|
||||
|
||||
1. **Depuis une TaskCard** : clique sur l'icône ▶ à droite de la card
|
||||
2. **Depuis le détail d'une tâche** : bouton *Démarrer le timer*
|
||||
3. **Manuellement** : depuis */time-tracking*, créer une time entry sans tâche
|
||||
|
||||
### Arrêter
|
||||
|
||||
- Clique sur ⏹ sur la card de la tâche en cours
|
||||
- Ou depuis la sidebar (icône timer pulsante en orange `#F18619`)
|
||||
|
||||
> 💡 Un seul timer actif à la fois. Démarrer un nouveau timer arrête automatiquement le précédent.
|
||||
|
||||
## Time entries
|
||||
|
||||
Chaque entrée a :
|
||||
|
||||
| Champ | Description |
|
||||
|---|---|
|
||||
| **Titre** | Description courte (ex: "Réunion daily") |
|
||||
| **Projet** | Obligatoire |
|
||||
| **Tâche** | Optionnel — lie l'entrée à une tâche précise |
|
||||
| **Tags** | Pour catégoriser (ex: "Backend", "Réunion") |
|
||||
| **Début / Fin** | Datetimes — la durée est calculée |
|
||||
| **User** | Qui a fait le travail |
|
||||
|
||||
### Vue *Time tracking*
|
||||
|
||||
Disponible en deux modes :
|
||||
|
||||
- **Vue semaine** : ligne par ligne, par jour
|
||||
- **Vue mois** : agrégation mensuelle, totaux par projet et par tag
|
||||
|
||||
### Filtres
|
||||
|
||||
- **Projet** (server-side)
|
||||
- **Tag** (server-side)
|
||||
- **User** (admin uniquement)
|
||||
- **Période** (date début / date fin)
|
||||
|
||||
## Édition
|
||||
|
||||
- Clique sur une time entry → drawer d'édition
|
||||
- Tu peux modifier projet, tâche, tags, dates a posteriori
|
||||
- La suppression est libre — pense à exporter avant si nécessaire
|
||||
|
||||
## Tags
|
||||
|
||||
Les tags sont **globaux** (partagés entre tous les projets, comme les statuts l'étaient avant les workflows). Définis depuis **Admin → Tags**.
|
||||
|
||||
> 📊 **Cas d'usage typique** : créer un tag par typologie d'activité (Dev, Réunion, Support, Veille) pour pouvoir agréger ton temps en fin de mois.
|
||||
@@ -1,62 +0,0 @@
|
||||
# Détail d'une tâche
|
||||
|
||||
## Champs principaux
|
||||
|
||||
| Champ | Notes |
|
||||
|---|---|
|
||||
| **Numéro** | Auto-incrémenté **par projet** (ex: `SIRH-1`, `SIRH-2`, `CRM-1`…) |
|
||||
| **Titre** | Obligatoire |
|
||||
| **Description** | Markdown supporté (preview disponible) |
|
||||
| **Statut** | Doit appartenir au workflow du projet (sinon erreur 422) |
|
||||
| **Priorité** | Basse / Moyenne / Haute — couleurs personnalisables |
|
||||
| **Effort** | S / M / L / XL / XXL — pour estimer la charge |
|
||||
| **Assigné** | Un seul user responsable |
|
||||
| **Collaborateurs** | Multiples — visibles via icône `mdi:account-group` |
|
||||
| **Groupe** | Optionnel — regroupe des tâches au sein d'un projet |
|
||||
| **Tags** | Globaux, plusieurs par tâche |
|
||||
| **Deadline** | Date — un badge coloré apparaît sur la card |
|
||||
| **Scheduled start / end** | Planification calendrier (sync optionnelle) |
|
||||
|
||||
## Récurrence
|
||||
|
||||
Une tâche peut être **récurrente** (icône 🔁 sur la card) :
|
||||
|
||||
- **Type** : quotidien, hebdomadaire, mensuel
|
||||
- **Intervalle** : tous les N jours/semaines/mois
|
||||
- **Jours de la semaine** (pour le mode hebdomadaire) : `monday`, `tuesday`, etc.
|
||||
|
||||
Chaque occurrence est gérée séparément ; cocher une tâche récurrente comme *Terminée* peut générer l'occurrence suivante selon le pattern.
|
||||
|
||||
## Sync calendrier
|
||||
|
||||
Si Zimbra est configuré (cf. Intégrations), tu peux activer **Sync calendrier** sur une tâche planifiée pour qu'elle apparaisse dans ton calendrier Zimbra (CalDav).
|
||||
|
||||
Icônes correspondantes :
|
||||
- 🟢 `mdi:calendar-check` → sync OK
|
||||
- 🔴 `mdi:alert-circle` → erreur de sync (passe sur l'icône pour le détail)
|
||||
|
||||
## Documents
|
||||
|
||||
Chaque tâche peut avoir des **documents attachés** (PDF, images, etc.) :
|
||||
|
||||
- Drag & drop dans la tâche pour uploader
|
||||
- Validation du **MIME type côté serveur** (pas seulement l'extension)
|
||||
- Téléchargement via lien dédié
|
||||
|
||||
## Liaison Gitea (si configuré)
|
||||
|
||||
Si le projet a un repo Gitea lié, tu peux :
|
||||
|
||||
- **Créer une branche** depuis la tâche : `feature/` `fix/` `refactor/` `hotfix/` `chore/` (5 types disponibles)
|
||||
- Convention de nommage : `<type>/<CODE>-<NUMBER>-<slug>` (ex: `feature/SIRH-12-add-login`)
|
||||
- **Voir les PRs** liées (état CI inclus)
|
||||
|
||||
## Liaison ticket client
|
||||
|
||||
Si la tâche découle d'un ticket client, l'icône 👤 (`heroicons:user-circle`) bleue apparaît avec le numéro du ticket (ex: `CT-001`).
|
||||
|
||||
## Commentaires & notifications
|
||||
|
||||
- Ajouter un commentaire notifie les watchers (assigné, collaborateurs)
|
||||
- Les @mentions notifient l'utilisateur cité
|
||||
- La cloche en topbar (`NotificationBell`) liste toutes les notifications non lues
|
||||
@@ -1,43 +0,0 @@
|
||||
# Portal client
|
||||
|
||||
> 🎫 Section dédiée aux utilisateurs avec le rôle **ROLE_CLIENT**.
|
||||
|
||||
## Accès
|
||||
|
||||
Les utilisateurs *client* sont **automatiquement redirigés vers `/portal`** après login. Ils ne voient pas les vues internes (projets, time tracking, admin).
|
||||
|
||||
## Ce que voit un client
|
||||
|
||||
- 📋 La liste de ses **projets autorisés** (définis par l'admin dans le user)
|
||||
- 🎫 Sur chaque projet, la liste de ses **tickets** (ses créations uniquement)
|
||||
- ➕ Le bouton **Nouveau ticket** sur chaque projet
|
||||
|
||||
## Soumettre un ticket
|
||||
|
||||
Depuis `/portal/projects/<id>/new-ticket` :
|
||||
|
||||
| Champ | Description |
|
||||
|---|---|
|
||||
| **Type** | `bug` / `improvement` / `other` |
|
||||
| **Titre** | Court et descriptif |
|
||||
| **Description** | Détails — markdown supporté |
|
||||
| **URL** | Optionnel — page où le problème se manifeste |
|
||||
|
||||
Le ticket est automatiquement numéroté **par projet** (ex: `CT-001`).
|
||||
|
||||
## Statuts d'un ticket
|
||||
|
||||
| Statut | Visible côté client | Signification |
|
||||
|---|---|---|
|
||||
| `new` | Oui | Reçu, pas encore traité |
|
||||
| `in_progress` | Oui | Une tâche interne y est liée |
|
||||
| `done` | Oui | Résolu et clôturé |
|
||||
| `rejected` | Oui | Non retenu (avec commentaire explicatif) |
|
||||
|
||||
Le `statusComment` est visible par le client quand fourni.
|
||||
|
||||
## Côté équipe interne
|
||||
|
||||
- Les tickets apparaissent dans **Admin → Tickets client**
|
||||
- On peut **transformer un ticket en tâche** (la tâche garde une référence au ticket — icône 👤 bleue sur la card)
|
||||
- Le client voit l'avancement passer en `in_progress` automatiquement quand une tâche est liée
|
||||
@@ -1,66 +0,0 @@
|
||||
# Administration
|
||||
|
||||
> 🛡️ Section visible uniquement par les utilisateurs **ROLE_ADMIN**.
|
||||
|
||||
L'admin (`/admin`) est divisé en plusieurs onglets, chacun gérant une ressource globale ou une intégration.
|
||||
|
||||
## Onglet *Clients*
|
||||
|
||||
- Liste des clients (entreprise / organisation)
|
||||
- Champs : nom, email, téléphone, adresse
|
||||
- Lier un client à des projets
|
||||
|
||||
## Onglet *Workflows*
|
||||
|
||||
⭐ **Nouveau** — remplace l'ancien onglet *Statuts*.
|
||||
|
||||
- Lister les workflows existants
|
||||
- **Créer un workflow** : nom, isDefault (un seul à la fois), liste de statuts éditables inline
|
||||
- Chaque statut : libellé, couleur, position, **catégorie** (5 valeurs canoniques), isFinal
|
||||
- **Éditer** un workflow modifie les statuts (sync intelligent : create / update / delete par diff)
|
||||
|
||||
> ⚠️ Supprimer un workflow lié à un projet renvoie une erreur **409**. Réassigne d'abord les projets.
|
||||
|
||||
## Onglet *Efforts*
|
||||
|
||||
- Tailles d'effort (S, M, L, XL, XXL)
|
||||
- Globales (partagées entre tous les projets)
|
||||
|
||||
## Onglet *Priorités*
|
||||
|
||||
- Niveaux de priorité (Basse, Moyenne, Haute) + couleur
|
||||
- Une priorité "Haute" affiche un drapeau rouge `mdi:flag-variant` sur la card
|
||||
|
||||
## Onglet *Tags*
|
||||
|
||||
- Tags globaux (tâches **et** time entries)
|
||||
- Couleur personnalisable
|
||||
- Pas de hiérarchie (flat list)
|
||||
|
||||
## Onglet *Utilisateurs*
|
||||
|
||||
- Créer / éditer / désactiver
|
||||
- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT`
|
||||
- **ROLE_CLIENT** : associer un *client* et une liste de *projets autorisés*
|
||||
- Reset password depuis l'admin
|
||||
|
||||
> 🔐 Un user *admin+client* (les deux rôles) **n'est pas bloqué** par le middleware portal — le check est sur `ROLE_CLIENT && !ROLE_ADMIN`.
|
||||
|
||||
## Onglet *Gitea*
|
||||
|
||||
- URL serveur + token API
|
||||
- Lier un projet à un repo : `giteaOwner` + `giteaRepo`
|
||||
- Active les fonctionnalités branches / PRs sur les tâches
|
||||
|
||||
## Onglet *BookStack*
|
||||
|
||||
- URL + token API
|
||||
- Lier un projet à un **shelf** BookStack (`bookstackShelfId`)
|
||||
- Les tâches peuvent être liées à des pages BookStack (cf. `TaskBookStackLink`)
|
||||
|
||||
## Onglet *Zimbra*
|
||||
|
||||
- URL serveur + credentials (chiffrés via libsodium)
|
||||
- Configure le calendrier CalDav par défaut
|
||||
- Test de connexion intégré
|
||||
- Active la **sync calendrier** sur les tâches planifiées
|
||||
@@ -1,66 +0,0 @@
|
||||
# Intégrations
|
||||
|
||||
Lesstime s'intègre avec **3 outils externes** pour fluidifier le workflow dev.
|
||||
|
||||
## 🌳 Gitea
|
||||
|
||||
Lesstime parle à un serveur Gitea pour automatiser les conventions de branches et suivre les PRs.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. **Admin → Gitea** : URL serveur + token API
|
||||
2. Sur un projet : définir `giteaOwner` (org/user) et `giteaRepo` (nom du repo)
|
||||
|
||||
### Utilisation
|
||||
|
||||
Sur une tâche, le panneau Gitea propose :
|
||||
|
||||
- **Créer une branche** : choisir un type (`feature` / `fix` / `refactor` / `hotfix` / `chore`)
|
||||
- La branche est nommée automatiquement : `<type>/<PROJECT_CODE>-<NUMBER>-<slug-du-titre>`
|
||||
- **Lister les PRs liées** : par convention, toute PR qui contient `<PROJECT_CODE>-<NUMBER>` dans son nom ou sa description est reliée
|
||||
- **État CI** : ✅ ou ❌ affiché si le repo a des Actions/Workflows configurées
|
||||
|
||||
> 💡 La convention `<PROJECT_CODE>-<NUMBER>` permet à Gitea et Lesstime de se synchroniser **sans webhook** — juste par parsing des noms.
|
||||
|
||||
## 📚 BookStack
|
||||
|
||||
Lien tâche → documentation.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. **Admin → BookStack** : URL + token (token ID + token secret, chiffrés via libsodium)
|
||||
2. Sur un projet : définir `bookstackShelfId` + `bookstackShelfName`
|
||||
|
||||
### Utilisation
|
||||
|
||||
- Depuis une tâche : bouton **Lier à une page BookStack**
|
||||
- Sélectionner la page dans le shelf du projet
|
||||
- Le lien est bidirectionnel (BookStack peut afficher les tâches liées)
|
||||
|
||||
## 📅 Zimbra (CalDav)
|
||||
|
||||
Sync calendrier pour les tâches planifiées.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. **Admin → Zimbra** :
|
||||
- URL serveur (ex: `https://mail.ovh.com`)
|
||||
- Username (ex: `lesstime@ovh.fr`)
|
||||
- Password (chiffré côté serveur)
|
||||
- Calendar path (ex: `/dav/lesstime@ovh.fr/Calendar/`)
|
||||
- **Test de connexion** intégré
|
||||
2. Active la config (toggle `enabled`)
|
||||
|
||||
### Utilisation
|
||||
|
||||
Sur une tâche avec **scheduled start + end** :
|
||||
|
||||
1. Cocher **Sync calendrier**
|
||||
2. Au save, Lesstime crée/met à jour l'événement CalDav
|
||||
3. L'icône `mdi:calendar-check` (verte) apparaît sur la card si succès
|
||||
4. L'icône `mdi:alert-circle` (rouge) apparaît si erreur — passe dessus pour voir le détail
|
||||
|
||||
### Limites
|
||||
|
||||
- **Pas de retour Zimbra → Lesstime** : si tu modifies l'événement dans Zimbra, Lesstime ne le voit pas
|
||||
- **Récurrences** : les patterns RRULE basiques sont supportés (daily, weekly avec jours, monthly)
|
||||
@@ -1,97 +0,0 @@
|
||||
# Token MCP & API
|
||||
|
||||
Lesstime expose un serveur **MCP** (Model Context Protocol) qui permet à un assistant IA (Claude, Cursor, etc.) de piloter ton instance Lesstime — créer des tâches, lire des projets, démarrer un timer, etc.
|
||||
|
||||
## Générer ton token
|
||||
|
||||
1. Va sur **Profil** (avatar → Profil)
|
||||
2. Section **Token MCP** → **Générer un token**
|
||||
3. **Copie le token immédiatement** — il ne sera plus affiché ensuite
|
||||
|
||||
> 🔐 **Sécurité** : Le token donne accès à toutes les actions de ton compte. Ne le partage jamais. Tu peux le régénérer à tout moment (l'ancien sera révoqué).
|
||||
|
||||
## Configurer Claude Code
|
||||
|
||||
Dans `.mcp.json` (à la racine de ton projet) :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"lesstime": {
|
||||
"type": "http",
|
||||
"url": "https://ton-instance-lesstime/_mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer TON_TOKEN_ICI"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pour une instance locale :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"lesstime-local": {
|
||||
"command": "docker",
|
||||
"args": ["exec", "-i", "php-lesstime-fpm", "php", "bin/console", "mcp:server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tools disponibles (27 au total)
|
||||
|
||||
### Projets
|
||||
|
||||
- `list-projects`, `get-project`, `create-project`, `update-project`
|
||||
|
||||
### Tâches
|
||||
|
||||
- `list-tasks` (avec filtres : projet, assigné, statut, archived…)
|
||||
- `get-task`, `create-task`, `update-task`, `delete-task`
|
||||
|
||||
### Métadonnées
|
||||
|
||||
- `list-statuses` (param **`projectId`** optionnel — sans : tous les statuts ; avec : statuts du workflow du projet)
|
||||
- `list-priorities`, `list-efforts`, `list-tags`
|
||||
|
||||
### Workflows ⭐ Nouveau
|
||||
|
||||
- `list-workflows` — liste tous les workflows avec leurs statuts groupés
|
||||
- `switch-project-workflow` (ROLE_ADMIN) — change le workflow d'un projet avec mapping
|
||||
|
||||
### Time tracking
|
||||
|
||||
- `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry`
|
||||
|
||||
### Récurrence
|
||||
|
||||
- `create-task-recurrence`, `update-task-recurrence`, `delete-task-recurrence`
|
||||
|
||||
### Groupes / Users / Clients
|
||||
|
||||
- `list-groups`, `create-group`, `update-group`
|
||||
- `list-users`, `list-clients`
|
||||
|
||||
## Règles importantes
|
||||
|
||||
> ⚠️ **Statut hors workflow rejeté** : si tu appelles `create-task` ou `update-task` avec un `status` qui n'appartient pas au workflow du projet, l'appel est rejeté avec **422 Validation error**. Utilise `list-statuses(projectId)` pour découvrir les statuts valides du projet.
|
||||
|
||||
## Exemples de prompts
|
||||
|
||||
```
|
||||
"Crée une tâche dans Lesstime sur le projet SIRH avec le titre
|
||||
'Ajouter l'export PDF' et la priorité Haute, assignée à alice"
|
||||
```
|
||||
|
||||
```
|
||||
"Liste mes tâches en cours dans le projet CRM"
|
||||
```
|
||||
|
||||
```
|
||||
"Démarre un timer sur la tâche SIRH-12 avec le tag Backend"
|
||||
```
|
||||
|
||||
L'agent appelle les bons tools tout seul si la description est claire.
|
||||
@@ -236,8 +236,7 @@
|
||||
"sortBy": "Trier par",
|
||||
"sortDefault": "Par défaut",
|
||||
"sortDeadline": "Échéance",
|
||||
"sortScheduledStart": "Date planifiée",
|
||||
"dropRefused": "Aucun statut de cette colonne dans le workflow de ce projet"
|
||||
"sortScheduledStart": "Date planifiée"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
@@ -493,120 +492,5 @@
|
||||
"weekly": "Hebdomadaire",
|
||||
"monthly": "Mensuel",
|
||||
"yearly": "Annuel"
|
||||
},
|
||||
"mail": {
|
||||
"title": "Messagerie",
|
||||
"sidebar": {
|
||||
"title": "Messagerie",
|
||||
"ariaLabel": "Accès à la messagerie, {count} messages non lus"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Configuration messagerie",
|
||||
"protocol": "Protocole",
|
||||
"imapSection": "Réception (IMAP)",
|
||||
"smtpSection": "Envoi (SMTP)",
|
||||
"host": "Serveur",
|
||||
"port": "Port",
|
||||
"encryption": "Chiffrement",
|
||||
"username": "Adresse e-mail",
|
||||
"password": "Mot de passe",
|
||||
"passwordSet": "Mot de passe déjà configuré — laisser vide pour conserver",
|
||||
"sentFolderPath": "Dossier des envois",
|
||||
"enabled": "Activer la synchronisation mail",
|
||||
"test": "Tester la connexion",
|
||||
"testSuccess": "Connexion IMAP réussie",
|
||||
"testFailed": "Échec de connexion",
|
||||
"save": "Enregistrer",
|
||||
"saveSuccess": "Configuration enregistrée",
|
||||
"ovhDefaultsHelp": "OVH : ssl0.ovh.net (port 993 IMAP / 465 SMTP)"
|
||||
},
|
||||
"folders": "Dossiers",
|
||||
"messages": "Messages",
|
||||
"viewer": "Lecture",
|
||||
"empty": {
|
||||
"folder": "Aucun dossier disponible.",
|
||||
"list": "Aucun message dans ce dossier.",
|
||||
"viewer": "Sélectionnez un message pour le lire."
|
||||
},
|
||||
"folderTree": {
|
||||
"expand": "Déplier le dossier",
|
||||
"collapse": "Replier le dossier"
|
||||
},
|
||||
"systemFolder": {
|
||||
"inbox": "Boîte de réception",
|
||||
"sent": "Éléments envoyés",
|
||||
"drafts": "Brouillons",
|
||||
"archive": "Archives",
|
||||
"trash": "Corbeille",
|
||||
"junk": "Indésirables"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Actualiser",
|
||||
"createTask": "Créer une tâche",
|
||||
"linkTask": "Lier à une tâche",
|
||||
"markRead": "Marquer comme lu",
|
||||
"markUnread": "Marquer comme non lu",
|
||||
"flag": "Marquer important",
|
||||
"unflag": "Retirer l'importance",
|
||||
"download": "Télécharger",
|
||||
"showImages": "Afficher les images"
|
||||
},
|
||||
"errors": {
|
||||
"syncFailed": "Erreur lors de la synchronisation.",
|
||||
"fetchFailed": "Impossible de charger les messages.",
|
||||
"notAuthorized": "Vous n'avez pas accès à la messagerie."
|
||||
},
|
||||
"configuration": {
|
||||
"saved": "Configuration mail enregistrée."
|
||||
},
|
||||
"task": {
|
||||
"created": "Tâche créée depuis le mail.",
|
||||
"linked": "Mail lié à la tâche.",
|
||||
"unlinked": "Lien supprimé."
|
||||
},
|
||||
"createTaskModal": {
|
||||
"title": "Créer une tâche depuis ce mail",
|
||||
"submit": "Créer la tâche",
|
||||
"projectLabel": "Projet *",
|
||||
"projectPlaceholder": "Sélectionner un projet",
|
||||
"groupLabel": "Groupe (optionnel)",
|
||||
"groupPlaceholder": "Aucun groupe",
|
||||
"statusLabel": "Statut",
|
||||
"assigneeLabel": "Assigné à",
|
||||
"assigneePlaceholder": "Aucun",
|
||||
"titleHint": "Le titre sera rempli depuis le sujet du mail.",
|
||||
"descriptionHint": "La description sera remplie depuis le corps du mail."
|
||||
},
|
||||
"linkTaskModal": {
|
||||
"title": "Lier à une tâche existante",
|
||||
"submit": "Lier la tâche",
|
||||
"searchPlaceholder": "Rechercher une tâche par titre…",
|
||||
"projectFilter": "Filtrer par projet",
|
||||
"projectAll": "Tous les projets",
|
||||
"empty": "Aucune tâche correspondante.",
|
||||
"loading": "Recherche en cours…"
|
||||
},
|
||||
"taskTab": {
|
||||
"title": "Mails",
|
||||
"empty": "Aucun mail lié à cette tâche.",
|
||||
"openInMailer": "Ouvrir dans la messagerie",
|
||||
"unlinkConfirm": "Délier ce mail ?"
|
||||
},
|
||||
"sync": {
|
||||
"dispatched": "Synchronisation lancée en arrière-plan."
|
||||
},
|
||||
"attachments": "Pièces jointes",
|
||||
"noAttachments": "Aucune pièce jointe.",
|
||||
"from": "De",
|
||||
"to": "À",
|
||||
"cc": "Cc",
|
||||
"date": "Date",
|
||||
"subject": "Sujet",
|
||||
"noSubject": "(Sans objet)",
|
||||
"loadMore": "Charger plus",
|
||||
"loading": "Chargement…",
|
||||
"hasAttachments": "Pièces jointes",
|
||||
"unread": "non lu | non lus",
|
||||
"remoteImagesBlocked": "Les images distantes sont masquées pour votre sécurité."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,23 +53,6 @@
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<div v-if="isMailVisible" class="relative">
|
||||
<SidebarLink
|
||||
to="/mail"
|
||||
icon="mdi:email-outline"
|
||||
:label="$t('mail.sidebar.title')"
|
||||
:collapsed="sidebarIsCollapsed"
|
||||
@click="ui.closeMobileSidebar()"
|
||||
/>
|
||||
<span
|
||||
v-if="mailStore.globalUnreadCount > 0"
|
||||
class="pointer-events-none absolute right-3 top-1/2 flex h-5 min-w-5 -translate-y-1/2 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
|
||||
:class="{ 'right-1 top-1 translate-y-0': sidebarIsCollapsed }"
|
||||
:aria-label="`${mailStore.globalUnreadCount} messages non lus`"
|
||||
>
|
||||
{{ mailStore.globalUnreadCount > 99 ? '99+' : mailStore.globalUnreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<SidebarLink
|
||||
to="/projects"
|
||||
icon="mdi:folder-outline"
|
||||
@@ -179,18 +162,9 @@ import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const mailStore = useMailStore()
|
||||
const {version} = useAppVersion()
|
||||
const route = useRoute()
|
||||
|
||||
const isMailVisible = computed(() => {
|
||||
const roles: string[] = auth.user?.roles ?? []
|
||||
const isClientOnly = roles.includes('ROLE_CLIENT')
|
||||
&& !roles.includes('ROLE_ADMIN')
|
||||
&& !roles.includes('ROLE_USER')
|
||||
return !isClientOnly && (roles.includes('ROLE_USER') || roles.includes('ROLE_ADMIN'))
|
||||
})
|
||||
|
||||
// On mobile, sidebar is always expanded (not collapsed icon mode)
|
||||
const sidebarIsCollapsed = computed(() => {
|
||||
if (ui.sidebarOpen) return false
|
||||
@@ -233,17 +207,6 @@ watch(
|
||||
|
||||
onMounted(() => {
|
||||
timerStore.fetchActive()
|
||||
if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => auth.user, (user) => {
|
||||
if (!user) {
|
||||
mailStore.stopPolling()
|
||||
} else if (isMailVisible.value) {
|
||||
mailStore.startPolling()
|
||||
}
|
||||
})
|
||||
|
||||
const completeDrawerOpen = ref(false)
|
||||
|
||||
33
frontend/package-lock.json
generated
33
frontend/package-lock.json
generated
@@ -15,7 +15,6 @@
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"dompurify": "^3.4.5",
|
||||
"marked": "^18.0.0",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
@@ -24,9 +23,6 @@
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -5863,16 +5859,6 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
@@ -5919,13 +5905,6 @@
|
||||
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
@@ -8121,15 +8100,6 @@
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz",
|
||||
"integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
@@ -12253,6 +12223,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
@@ -12273,6 +12244,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
@@ -12306,6 +12278,7 @@
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
|
||||
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@vuepic/vue-datepicker": "^12.1.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"dompurify": "^3.4.5",
|
||||
"marked": "^18.0.0",
|
||||
"nuxt": "^4.3.1",
|
||||
"nuxt-toast": "^1.4.0",
|
||||
@@ -28,8 +27,5 @@
|
||||
"vue-advanced-cropper": "^2.8.9",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
||||
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
|
||||
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
|
||||
<AdminMailTab v-if="activeTab === 'mail'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -49,7 +48,6 @@ const tabs = [
|
||||
{ key: 'gitea', label: 'Gitea' },
|
||||
{ key: 'bookstack', label: 'BookStack' },
|
||||
{ key: 'zimbra', label: 'Zimbra' },
|
||||
{ key: 'mail', label: 'Mail' },
|
||||
] as const
|
||||
|
||||
type TabKey = typeof tabs[number]['key']
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked'
|
||||
|
||||
useHead({ title: 'Aide' })
|
||||
|
||||
type Section = {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
accent: string
|
||||
roles: ('admin' | 'user' | 'client')[]
|
||||
content: string
|
||||
}
|
||||
|
||||
const rawModules = import.meta.glob('~/content/help/*.md', { eager: true, query: '?raw', import: 'default' }) as Record<string, string>
|
||||
|
||||
const META: Record<string, { title: string, icon: string, accent: string, roles: ('admin' | 'user' | 'client')[] }> = {
|
||||
'01-getting-started': { title: 'Bienvenue', icon: 'mdi:hand-wave', accent: 'from-amber-400 to-rose-500', roles: ['admin', 'user', 'client'] },
|
||||
'02-projects-workflows': { title: 'Projets & Workflows', icon: 'mdi:view-column-outline', accent: 'from-indigo-500 to-fuchsia-500', roles: ['admin', 'user'] },
|
||||
'03-my-tasks': { title: 'Mes tâches', icon: 'mdi:checkbox-marked-circle-outline', accent: 'from-sky-500 to-cyan-500', roles: ['admin', 'user'] },
|
||||
'04-time-tracking': { title: 'Time tracking', icon: 'mdi:timer-outline', accent: 'from-emerald-500 to-teal-500', roles: ['admin', 'user'] },
|
||||
'05-tasks-detail': { title: 'Tâches en détail', icon: 'mdi:file-document-edit-outline', accent: 'from-violet-500 to-purple-600', roles: ['admin', 'user'] },
|
||||
'06-client-portal': { title: 'Portal client', icon: 'mdi:account-tie-outline', accent: 'from-orange-500 to-amber-500', roles: ['admin', 'client'] },
|
||||
'07-admin': { title: 'Administration', icon: 'mdi:shield-crown-outline', accent: 'from-rose-500 to-pink-600', roles: ['admin'] },
|
||||
'08-integrations': { title: 'Intégrations', icon: 'mdi:puzzle-outline', accent: 'from-blue-500 to-indigo-500', roles: ['admin', 'user'] },
|
||||
'09-mcp-api': { title: 'Token MCP & API', icon: 'mdi:robot-outline', accent: 'from-slate-700 to-slate-900', roles: ['admin', 'user'] },
|
||||
}
|
||||
|
||||
const sections = computed<Section[]>(() => {
|
||||
return Object.entries(rawModules).map(([path, raw]) => {
|
||||
const id = path.split('/').pop()!.replace(/\.md$/, '')
|
||||
const meta = META[id] ?? { title: id, icon: 'mdi:file-document-outline', accent: 'from-neutral-500 to-neutral-700', roles: ['admin', 'user', 'client'] as ('admin' | 'user' | 'client')[] }
|
||||
return { id, ...meta, content: raw }
|
||||
}).sort((a, b) => a.id.localeCompare(b.id))
|
||||
})
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const userRole = computed<'admin' | 'user' | 'client'>(() => {
|
||||
const roles = auth.user?.roles ?? []
|
||||
if (roles.includes('ROLE_ADMIN')) return 'admin'
|
||||
if (roles.includes('ROLE_CLIENT')) return 'client'
|
||||
return 'user'
|
||||
})
|
||||
|
||||
const visibleSections = computed(() =>
|
||||
sections.value.filter(s => s.roles.includes(userRole.value)),
|
||||
)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeId = ref(visibleSections.value[0]?.id ?? '')
|
||||
|
||||
onMounted(() => {
|
||||
const hash = (route.query.section as string) ?? route.hash.replace('#', '')
|
||||
if (hash && visibleSections.value.some(s => s.id === hash)) {
|
||||
activeId.value = hash
|
||||
}
|
||||
})
|
||||
|
||||
watch(activeId, (id) => {
|
||||
router.replace({ query: { ...route.query, section: id } })
|
||||
})
|
||||
|
||||
const activeSection = computed(() => visibleSections.value.find(s => s.id === activeId.value) ?? visibleSections.value[0])
|
||||
|
||||
const renderedHtml = computed(() => {
|
||||
if (!activeSection.value) return ''
|
||||
return marked.parse(activeSection.value.content, { async: false }) as string
|
||||
})
|
||||
|
||||
const prevSection = computed(() => {
|
||||
const idx = visibleSections.value.findIndex(s => s.id === activeId.value)
|
||||
return idx > 0 ? visibleSections.value[idx - 1] : null
|
||||
})
|
||||
|
||||
const nextSection = computed(() => {
|
||||
const idx = visibleSections.value.findIndex(s => s.id === activeId.value)
|
||||
return idx >= 0 && idx < visibleSections.value.length - 1 ? visibleSections.value[idx + 1] : null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-[calc(100vh-60px)] flex-col lg:flex-row">
|
||||
<!-- Sidebar -->
|
||||
<aside class="shrink-0 border-b border-neutral-200 bg-gradient-to-b from-white to-neutral-50 px-3 py-4 lg:w-72 lg:border-b-0 lg:border-r lg:px-4 lg:py-6">
|
||||
<div class="mb-4 flex items-center gap-2 lg:mb-6">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary-500 to-primary-700 text-white shadow-sm">
|
||||
<Icon name="mdi:lifebuoy" size="20" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-base font-bold text-neutral-900">Centre d'aide</h1>
|
||||
<p class="text-xs text-neutral-500">Lesstime — Guide utilisateur</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-row gap-1 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0">
|
||||
<button
|
||||
v-for="section in visibleSections"
|
||||
:key="section.id"
|
||||
type="button"
|
||||
class="group flex shrink-0 items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium transition-all lg:shrink"
|
||||
:class="activeId === section.id
|
||||
? 'bg-white text-neutral-900 shadow-sm ring-1 ring-neutral-200'
|
||||
: 'text-neutral-600 hover:bg-white hover:text-neutral-900'"
|
||||
@click="activeId = section.id"
|
||||
>
|
||||
<span
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br text-white shadow-sm"
|
||||
:class="section.accent"
|
||||
>
|
||||
<Icon :name="section.icon" size="16" />
|
||||
</span>
|
||||
<span class="whitespace-nowrap lg:whitespace-normal">{{ section.title }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="flex-1 px-4 py-6 sm:px-8 lg:px-12 lg:py-10">
|
||||
<div v-if="activeSection" class="mx-auto max-w-3xl">
|
||||
<!-- Hero header -->
|
||||
<div
|
||||
class="mb-8 overflow-hidden rounded-2xl bg-gradient-to-br p-6 text-white shadow-lg sm:p-8"
|
||||
:class="activeSection.accent"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-white/20 backdrop-blur-sm">
|
||||
<Icon :name="activeSection.icon" size="28" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wider text-white/80">Section</p>
|
||||
<h2 class="text-2xl font-bold tracking-tight sm:text-3xl">{{ activeSection.title }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Markdown content -->
|
||||
<article
|
||||
class="prose prose-neutral max-w-none prose-headings:font-bold prose-headings:tracking-tight prose-h1:hidden prose-h2:mt-10 prose-h2:border-b prose-h2:border-neutral-200 prose-h2:pb-2 prose-h3:text-neutral-800 prose-a:text-primary-600 prose-strong:text-neutral-900 prose-code:rounded prose-code:bg-neutral-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:font-medium prose-code:text-rose-600 prose-code:before:content-none prose-code:after:content-none prose-pre:rounded-xl prose-pre:bg-slate-900 prose-table:border prose-table:border-neutral-200 prose-th:bg-neutral-50 prose-th:px-3 prose-th:py-2 prose-td:px-3 prose-td:py-2 prose-blockquote:rounded-r-lg prose-blockquote:border-l-4 prose-blockquote:border-amber-400 prose-blockquote:bg-amber-50 prose-blockquote:px-4 prose-blockquote:py-2 prose-blockquote:not-italic prose-blockquote:text-amber-900"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
|
||||
<!-- Footer nav -->
|
||||
<div class="mt-12 flex items-center justify-between border-t border-neutral-200 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:invisible"
|
||||
:disabled="!prevSection"
|
||||
@click="prevSection && (activeId = prevSection.id)"
|
||||
>
|
||||
<Icon name="mdi:arrow-left" size="18" />
|
||||
<span>{{ prevSection?.title ?? '' }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 disabled:invisible"
|
||||
:disabled="!nextSection"
|
||||
@click="nextSection && (activeId = nextSection.id)"
|
||||
>
|
||||
<span>{{ nextSection?.title ?? '' }}</span>
|
||||
<Icon name="mdi:arrow-right" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,182 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Task } from '~/services/dto/task'
|
||||
import { useMailStore } from '~/stores/mail'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
useHead({ title: t('mail.title') })
|
||||
|
||||
// ─── Contrôle d'accès ROLE_CLIENT ─────────────────────────────────────────
|
||||
// Le middleware global gère auth + ROLE_CLIENT → /portal. Ici : double check
|
||||
// en SPA car la session peut être hydratée après le rendu initial.
|
||||
|
||||
const isClientOnly = computed(() =>
|
||||
auth.user?.roles?.includes('ROLE_CLIENT') === true
|
||||
&& auth.user?.roles?.includes('ROLE_ADMIN') !== true,
|
||||
)
|
||||
|
||||
if (isClientOnly.value) {
|
||||
await navigateTo('/portal')
|
||||
}
|
||||
|
||||
// ─── Store ────────────────────────────────────────────────────────────────
|
||||
|
||||
const store = useMailStore()
|
||||
const {
|
||||
folderTree,
|
||||
selectedFolderPath,
|
||||
messages,
|
||||
messagesLoading,
|
||||
hasMoreMessages,
|
||||
selectedMessageId,
|
||||
selectedMessageDetail,
|
||||
detailLoading,
|
||||
} = storeToRefs(store)
|
||||
|
||||
// ─── Init : charge les dossiers + deep-link ───────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
if (isClientOnly.value) {
|
||||
router.replace('/portal')
|
||||
return
|
||||
}
|
||||
|
||||
if (folderTree.value.length === 0) {
|
||||
await store.fetchFolders()
|
||||
}
|
||||
|
||||
if (!selectedFolderPath.value && folderTree.value.length > 0) {
|
||||
const inbox = folderTree.value.find((f) => f.path.toUpperCase() === 'INBOX')
|
||||
const first = folderTree.value[0]
|
||||
const target = inbox?.path ?? first?.path
|
||||
if (target) {
|
||||
await store.selectFolder(target)
|
||||
}
|
||||
}
|
||||
|
||||
const messageIdParam = route.query.messageId
|
||||
if (messageIdParam) {
|
||||
const id = parseInt(String(messageIdParam), 10)
|
||||
if (!isNaN(id)) {
|
||||
await store.selectMessage(id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleFolderSelect(path: string): Promise<void> {
|
||||
await store.selectFolder(path)
|
||||
if (route.query.messageId) {
|
||||
const nextQuery = { ...route.query }
|
||||
delete nextQuery.messageId
|
||||
router.replace({ query: nextQuery })
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMessageSelect(id: number): Promise<void> {
|
||||
await store.selectMessage(id)
|
||||
}
|
||||
|
||||
function handleLoadMore(): void {
|
||||
store.fetchMessages(true)
|
||||
}
|
||||
|
||||
// ─── Modals Phase 6 ────────────────────────────────────────────────────────
|
||||
|
||||
const showCreateTaskModal = ref(false)
|
||||
const showLinkTaskModal = ref(false)
|
||||
const activeMailIdForModal = ref<number | null>(null)
|
||||
|
||||
function handleCreateTask(mailId: number): void {
|
||||
activeMailIdForModal.value = mailId
|
||||
showCreateTaskModal.value = true
|
||||
}
|
||||
|
||||
function handleLinkTask(mailId: number): void {
|
||||
activeMailIdForModal.value = mailId
|
||||
showLinkTaskModal.value = true
|
||||
}
|
||||
|
||||
function handleTaskCreated(_task: Task): void {
|
||||
showCreateTaskModal.value = false
|
||||
// La tâche est créée et liée côté backend — toast géré par useMailService.createTaskFromMail
|
||||
}
|
||||
|
||||
function handleTaskLinked(_taskId: number): void {
|
||||
showLinkTaskModal.value = false
|
||||
// Toast géré par useMailService.linkTask
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<div class="flex flex-shrink-0 items-center justify-between border-b border-neutral-200 bg-white px-4 py-3">
|
||||
<h1 class="text-lg font-semibold text-neutral-900">
|
||||
{{ t('mail.title') }}
|
||||
</h1>
|
||||
<MailRefreshButton />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<aside class="w-[220px] flex-shrink-0 overflow-y-auto border-r border-neutral-200 bg-neutral-50 py-2">
|
||||
<p class="mb-1 px-3 text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
{{ t('mail.folders') }}
|
||||
</p>
|
||||
<MailFolderTree
|
||||
:folders="folderTree"
|
||||
:selected-path="selectedFolderPath"
|
||||
@select="handleFolderSelect"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div class="flex w-[320px] flex-shrink-0 flex-col overflow-hidden border-r border-neutral-200 bg-white">
|
||||
<div class="flex flex-shrink-0 items-center justify-between border-b border-neutral-100 px-3 py-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
{{ t('mail.messages') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<MailMessageList
|
||||
:messages="messages"
|
||||
:selected-id="selectedMessageId"
|
||||
:loading="messagesLoading"
|
||||
:has-more="hasMoreMessages"
|
||||
@select="handleMessageSelect"
|
||||
@load-more="handleLoadMore"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden bg-white">
|
||||
<MailMessageViewer
|
||||
:detail="selectedMessageDetail"
|
||||
:loading="detailLoading"
|
||||
@create-task="handleCreateTask"
|
||||
@link-task="handleLinkTask"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal créer tâche depuis mail -->
|
||||
<MailCreateTaskModal
|
||||
v-if="activeMailIdForModal !== null"
|
||||
v-model="showCreateTaskModal"
|
||||
:message-id="activeMailIdForModal"
|
||||
:message-detail="selectedMessageDetail"
|
||||
@created="handleTaskCreated"
|
||||
/>
|
||||
|
||||
<!-- Modal lier mail à tâche -->
|
||||
<MailLinkTaskModal
|
||||
v-if="activeMailIdForModal !== null"
|
||||
v-model="showLinkTaskModal"
|
||||
:message-id="activeMailIdForModal"
|
||||
@linked="handleTaskLinked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,7 +8,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
import type { Project } from '~/services/dto/project'
|
||||
import type { StatusCategory } from '~/services/dto/workflow'
|
||||
import { STATUS_CATEGORY_LABEL, STATUS_CATEGORY_COLOR, contrastText } from '~/services/dto/workflow'
|
||||
import { STATUS_CATEGORY_LABEL } from '~/services/dto/workflow'
|
||||
import { useTaskService } from '~/services/tasks'
|
||||
import { useTaskStatusService } from '~/services/task-statuses'
|
||||
import { useTaskEffortService } from '~/services/task-efforts'
|
||||
@@ -71,48 +71,6 @@ const selectedTask = ref<Task | null>(null)
|
||||
// Timer
|
||||
const timerStore = useTimerStore()
|
||||
|
||||
// Toast
|
||||
const toast = useToast()
|
||||
|
||||
// Drag & drop
|
||||
const dragOverCategory = ref<StatusCategory | null>(null)
|
||||
const pendingPicker = ref<{ statuses: TaskStatus[], task: Task, x: number, y: number } | null>(null)
|
||||
|
||||
function statusesForTaskCategory(task: Task, category: StatusCategory): TaskStatus[] {
|
||||
// GET /tasks n'embarque que l'IRI du workflow ; on résout depuis la liste projects chargée (qui embarque workflow.statuses).
|
||||
const project = projects.value.find(p => p.id === task.project?.id)
|
||||
const wf = project?.workflow
|
||||
if (!wf || typeof wf === 'string') return []
|
||||
return wf.statuses.filter(s => s.category === category)
|
||||
}
|
||||
|
||||
async function applyStatus(task: Task, status: TaskStatus): Promise<void> {
|
||||
await taskService.update(task.id, { status: `/api/task_statuses/${status.id}` })
|
||||
await loadTasks()
|
||||
}
|
||||
|
||||
function onDrop(category: StatusCategory, event: DragEvent): void {
|
||||
dragOverCategory.value = null
|
||||
const taskId = Number(event.dataTransfer?.getData('text/plain'))
|
||||
const task = tasks.value.find(t => t.id === taskId)
|
||||
if (!task) return
|
||||
const candidates = statusesForTaskCategory(task, category)
|
||||
if (candidates.length === 0) {
|
||||
toast.error({ message: t('myTasks.dropRefused') })
|
||||
return
|
||||
}
|
||||
if (candidates.length === 1) {
|
||||
void applyStatus(task, candidates[0])
|
||||
return
|
||||
}
|
||||
pendingPicker.value = { statuses: candidates, task, x: event.clientX, y: event.clientY }
|
||||
}
|
||||
|
||||
function onPickerChoice(status: TaskStatus): void {
|
||||
if (pendingPicker.value) void applyStatus(pendingPicker.value.task, status)
|
||||
pendingPicker.value = null
|
||||
}
|
||||
|
||||
function isTimerOnTask(task: Task): boolean {
|
||||
const entry = timerStore.activeEntry
|
||||
if (!entry?.task) return false
|
||||
@@ -439,16 +397,9 @@ onMounted(async () => {
|
||||
<div
|
||||
v-for="cat in CATEGORIES"
|
||||
:key="cat"
|
||||
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50 transition"
|
||||
:class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
|
||||
@dragover.prevent="dragOverCategory = cat"
|
||||
@dragleave="dragOverCategory = null"
|
||||
@drop="onDrop(cat, $event)"
|
||||
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 rounded-t-lg px-4 py-3 text-sm font-bold"
|
||||
:style="{ backgroundColor: STATUS_CATEGORY_COLOR[cat], color: contrastText(STATUS_CATEGORY_COLOR[cat]) }"
|
||||
>
|
||||
<div class="shrink-0 rounded-t-lg bg-neutral-200 px-4 py-3 text-sm font-bold text-neutral-800">
|
||||
{{ STATUS_CATEGORY_LABEL[cat] }} ({{ tasksByCategory(cat).length }})
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
@@ -530,16 +481,6 @@ onMounted(async () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- StatusPickerPopover (D&D ambiguity resolution) -->
|
||||
<StatusPickerPopover
|
||||
v-if="pendingPicker"
|
||||
:statuses="pendingPicker.statuses"
|
||||
:x="pendingPicker.x"
|
||||
:y="pendingPicker.y"
|
||||
@pick="onPickerChoice"
|
||||
@cancel="pendingPicker = null"
|
||||
/>
|
||||
|
||||
<!-- TaskModal -->
|
||||
<TaskModal
|
||||
v-model="taskModalOpen"
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
// Lecture de la configuration mail (singleton admin)
|
||||
export type MailConfigurationDto = {
|
||||
protocol: string | null
|
||||
imapHost: string | null
|
||||
imapPort: number | null
|
||||
imapEncryption: string | null
|
||||
smtpHost: string | null
|
||||
smtpPort: number | null
|
||||
smtpEncryption: string | null
|
||||
username: string | null
|
||||
sentFolderPath: string | null
|
||||
enabled: boolean
|
||||
hasPassword: boolean
|
||||
// password JAMAIS présent dans les réponses GET
|
||||
}
|
||||
|
||||
// Input PATCH configuration (password optionnel, write-only)
|
||||
export type MailConfigurationUpdateDto = {
|
||||
protocol?: string | null
|
||||
imapHost?: string | null
|
||||
imapPort?: number | null
|
||||
imapEncryption?: string | null
|
||||
smtpHost?: string | null
|
||||
smtpPort?: number | null
|
||||
smtpEncryption?: string | null
|
||||
username?: string | null
|
||||
sentFolderPath?: string | null
|
||||
enabled?: boolean
|
||||
password?: string // write-only, jamais retourné
|
||||
}
|
||||
|
||||
// Résultat du test de connexion
|
||||
export type MailTestConnectionResultDto = {
|
||||
ok: boolean
|
||||
foldersCount?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Dossier mail (peut être imbriqué)
|
||||
export type MailFolderDto = {
|
||||
path: string
|
||||
displayName: string
|
||||
parentPath: string | null
|
||||
unreadCount: number
|
||||
totalCount: number
|
||||
children?: MailFolderDto[]
|
||||
}
|
||||
|
||||
// Adresse mail (nom + email)
|
||||
export type MailAddressDto = {
|
||||
name: string | null
|
||||
email: string
|
||||
}
|
||||
|
||||
// En-tête d'un message (liste)
|
||||
export type MailMessageHeaderDto = {
|
||||
id: number
|
||||
messageId: string // identifiant IMAP unique
|
||||
folderPath: string
|
||||
subject: string | null
|
||||
fromName: string | null
|
||||
fromEmail: string | null
|
||||
toRecipients: MailAddressDto[]
|
||||
ccRecipients: MailAddressDto[]
|
||||
sentAt: string | null // ISO 8601
|
||||
receivedAt: string // ISO 8601
|
||||
isRead: boolean
|
||||
isFlagged: boolean
|
||||
hasAttachments: boolean
|
||||
linkedTaskIds: number[]
|
||||
}
|
||||
|
||||
// Pièce jointe (métadonnées uniquement, téléchargement via downloadId)
|
||||
export type MailAttachmentDto = {
|
||||
downloadId: string
|
||||
filename: string
|
||||
mimeType: string
|
||||
size: number // octets
|
||||
}
|
||||
|
||||
// Détail complet d'un message (enrichi avec body + PJ)
|
||||
export type MailMessageDetailDto = {
|
||||
header: MailMessageHeaderDto
|
||||
bodyHtml: string | null // HTML brut — TOUJOURS passer par sanitizeMailHtml() avant affichage
|
||||
bodyText: string | null // Fallback texte plain
|
||||
attachments: MailAttachmentDto[]
|
||||
}
|
||||
|
||||
// Page de messages paginée (cursor-based)
|
||||
export type MailMessagesPageDto = {
|
||||
items: MailMessageHeaderDto[]
|
||||
nextCursor: string | null // null = plus de page suivante
|
||||
total: number
|
||||
}
|
||||
|
||||
// Input : marquer lu/non-lu
|
||||
export type MailMessageReadInput = {
|
||||
read: boolean
|
||||
}
|
||||
|
||||
// Input : marquer étoilé/non-étoilé
|
||||
export type MailMessageFlagInput = {
|
||||
flagged: boolean
|
||||
}
|
||||
|
||||
// Input : créer une tâche depuis un mail
|
||||
export type MailCreateTaskInput = {
|
||||
projectId: number
|
||||
taskGroupId?: number | null
|
||||
assigneeId?: number
|
||||
statusId?: number
|
||||
}
|
||||
|
||||
// Input : lier une tâche existante à un mail
|
||||
export type MailLinkTaskInput = {
|
||||
taskId: number
|
||||
}
|
||||
|
||||
// Résultat de la sync manuelle
|
||||
export type MailSyncResultDto = {
|
||||
dispatched: boolean
|
||||
}
|
||||
@@ -10,25 +10,6 @@ export const STATUS_CATEGORY_LABEL: Record<StatusCategory, string> = {
|
||||
done: 'Terminé',
|
||||
}
|
||||
|
||||
/** Palette canonique des catégories (couleurs « classiques »), indépendante des workflows. */
|
||||
export const STATUS_CATEGORY_COLOR: Record<StatusCategory, string> = {
|
||||
todo: '#222783',
|
||||
in_progress: '#4A90D9',
|
||||
blocked: '#C62828',
|
||||
review: '#FF8F00',
|
||||
done: '#26A69A',
|
||||
}
|
||||
|
||||
/** Renvoie '#1f2937' (foncé) ou '#ffffff' (blanc) selon la luminance du fond, pour rester lisible. */
|
||||
export function contrastText(hex: string): string {
|
||||
const c = hex.replace('#', '')
|
||||
const r = parseInt(c.slice(0, 2), 16)
|
||||
const g = parseInt(c.slice(2, 4), 16)
|
||||
const b = parseInt(c.slice(4, 6), 16)
|
||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return lum > 0.6 ? '#1f2937' : '#ffffff'
|
||||
}
|
||||
|
||||
export type Workflow = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
import type {
|
||||
MailConfigurationDto,
|
||||
MailConfigurationUpdateDto,
|
||||
MailTestConnectionResultDto,
|
||||
MailFolderDto,
|
||||
MailMessageHeaderDto,
|
||||
MailMessageDetailDto,
|
||||
MailMessagesPageDto,
|
||||
MailMessageReadInput,
|
||||
MailMessageFlagInput,
|
||||
MailCreateTaskInput,
|
||||
MailLinkTaskInput,
|
||||
MailSyncResultDto,
|
||||
} from './dto/mail'
|
||||
import type { Task } from './dto/task'
|
||||
|
||||
type BackendMailMessage = {
|
||||
id: number
|
||||
messageId: string
|
||||
uid: number
|
||||
folderPath?: string
|
||||
subject: string | null
|
||||
fromAddress: string | null
|
||||
fromName: string | null
|
||||
toAddresses: string[] | null
|
||||
ccAddresses: string[] | null
|
||||
sentAt: string | null
|
||||
isRead: boolean
|
||||
isFlagged: boolean
|
||||
hasAttachments: boolean
|
||||
snippet?: string | null
|
||||
linkedTaskIds?: number[]
|
||||
}
|
||||
|
||||
function toAddressList(values: string[] | null | undefined): { email: string; name: string | null }[] {
|
||||
return (values ?? []).map((email) => ({ email, name: null }))
|
||||
}
|
||||
|
||||
function mapHeader(m: BackendMailMessage, fallbackFolderPath = ''): MailMessageHeaderDto {
|
||||
return {
|
||||
id: m.id,
|
||||
messageId: m.messageId,
|
||||
folderPath: m.folderPath ?? fallbackFolderPath,
|
||||
subject: m.subject,
|
||||
fromName: m.fromName,
|
||||
fromEmail: m.fromAddress,
|
||||
toRecipients: toAddressList(m.toAddresses),
|
||||
ccRecipients: toAddressList(m.ccAddresses),
|
||||
sentAt: m.sentAt,
|
||||
receivedAt: m.sentAt ?? '',
|
||||
isRead: m.isRead,
|
||||
isFlagged: m.isFlagged,
|
||||
hasAttachments: m.hasAttachments,
|
||||
linkedTaskIds: m.linkedTaskIds ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export function useMailService() {
|
||||
const api = useApi()
|
||||
|
||||
// ─── Configuration (Admin) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Récupère la configuration mail singleton.
|
||||
* Requiert ROLE_ADMIN — 403 sinon.
|
||||
*/
|
||||
async function getConfiguration(): Promise<MailConfigurationDto> {
|
||||
return api.get<MailConfigurationDto>('/mail/configuration')
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour la configuration mail (PATCH merge).
|
||||
* Si payload.password est fourni, il sera chiffré côté backend.
|
||||
* Jamais retourné en clair dans la réponse.
|
||||
*/
|
||||
async function updateConfiguration(
|
||||
payload: MailConfigurationUpdateDto,
|
||||
): Promise<MailConfigurationDto> {
|
||||
return api.patch<MailConfigurationDto>(
|
||||
'/mail/configuration',
|
||||
payload as Record<string, unknown>,
|
||||
{ toastSuccessKey: 'mail.configuration.saved' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Teste la connexion IMAP avec la configuration actuelle.
|
||||
* Requiert ROLE_ADMIN.
|
||||
*/
|
||||
async function testConfiguration(): Promise<MailTestConnectionResultDto> {
|
||||
return api.post<MailTestConnectionResultDto>('/mail/configuration/test', {})
|
||||
}
|
||||
|
||||
// ─── Dossiers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Liste tous les dossiers mail depuis la base (cache BDD, pas live IMAP).
|
||||
* Retourne une liste plate — la construction de l'arbre est faite dans le store
|
||||
* via le getter `folderTree`.
|
||||
*/
|
||||
async function listFolders(): Promise<MailFolderDto[]> {
|
||||
return api.get<MailFolderDto[]>('/mail/folders')
|
||||
}
|
||||
|
||||
// ─── Messages ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Liste les messages d'un dossier, paginés par cursor.
|
||||
* @param folderPath - Chemin du dossier (ex: "INBOX", "INBOX.Sent")
|
||||
* @param cursor - Opaque cursor retourné par la page précédente (undefined = première page)
|
||||
* @param limit - Nombre de messages par page (défaut backend : 50)
|
||||
*/
|
||||
async function listMessages(
|
||||
folderPath: string,
|
||||
cursor?: string,
|
||||
limit?: number,
|
||||
): Promise<MailMessagesPageDto> {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (cursor) query.cursor = cursor
|
||||
if (limit) query.limit = limit
|
||||
const path = `/mail/folders/${encodeURIComponent(folderPath)}/messages`
|
||||
const response = await api.get<{ messages: BackendMailMessage[]; nextCursor: string | null }>(
|
||||
path,
|
||||
query,
|
||||
)
|
||||
return {
|
||||
items: response.messages.map((m) => mapHeader(m, folderPath)),
|
||||
nextCursor: response.nextCursor,
|
||||
total: response.messages.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le détail complet d'un message (body live IMAP, cached 5 min).
|
||||
* @param id - ID BDD du message (MailMessage.id)
|
||||
*/
|
||||
async function getMessage(id: number): Promise<MailMessageDetailDto> {
|
||||
const response = await api.get<
|
||||
BackendMailMessage & {
|
||||
bodyHtml: string | null
|
||||
bodyText: string | null
|
||||
attachments: MailMessageDetailDto['attachments']
|
||||
}
|
||||
>(`/mail/messages/${id}`)
|
||||
return {
|
||||
header: mapHeader(response),
|
||||
bodyHtml: response.bodyHtml,
|
||||
bodyText: response.bodyText,
|
||||
attachments: response.attachments,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Actions sur les messages ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Marque un message comme lu ou non-lu.
|
||||
*/
|
||||
async function markRead(id: number, read: boolean): Promise<MailMessageHeaderDto> {
|
||||
const payload: MailMessageReadInput = { read }
|
||||
return api.post<MailMessageHeaderDto>(
|
||||
`/mail/messages/${id}/read`,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque un message comme étoilé ou non-étoilé.
|
||||
*/
|
||||
async function markFlagged(id: number, flagged: boolean): Promise<MailMessageHeaderDto> {
|
||||
const payload: MailMessageFlagInput = { flagged }
|
||||
return api.post<MailMessageHeaderDto>(
|
||||
`/mail/messages/${id}/flag`,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Intégration tâches ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Crée une nouvelle tâche à partir d'un mail (subject → titre, body → description).
|
||||
* @param mailId - ID BDD du message
|
||||
* @param input - Paramètres de la tâche à créer
|
||||
*/
|
||||
async function createTaskFromMail(
|
||||
mailId: number,
|
||||
input: MailCreateTaskInput,
|
||||
): Promise<Task> {
|
||||
return api.post<Task>(
|
||||
`/mail/messages/${mailId}/create-task`,
|
||||
input as unknown as Record<string, unknown>,
|
||||
{ toastSuccessKey: 'mail.task.created' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lie un mail à une tâche existante.
|
||||
* @param mailId - ID BDD du message
|
||||
* @param taskId - ID de la tâche existante
|
||||
*/
|
||||
async function linkTask(mailId: number, taskId: number): Promise<void> {
|
||||
const payload: MailLinkTaskInput = { taskId }
|
||||
await api.post<void>(
|
||||
`/mail/messages/${mailId}/link-task`,
|
||||
payload as unknown as Record<string, unknown>,
|
||||
{ toastSuccessKey: 'mail.task.linked' },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime le lien entre un mail et une tâche.
|
||||
* @param mailId - ID BDD du message
|
||||
* @param taskId - ID de la tâche
|
||||
*/
|
||||
async function unlinkTask(mailId: number, taskId: number): Promise<void> {
|
||||
await api.delete<void>(`/mail/messages/${mailId}/link-task/${taskId}`, {}, {
|
||||
toastSuccessKey: 'mail.task.unlinked',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les mails liés à une tâche (pour l'onglet "Mails" du TaskDrawer — Phase 6).
|
||||
* @param taskId - ID de la tâche
|
||||
*/
|
||||
async function listMailsForTask(taskId: number): Promise<MailMessageHeaderDto[]> {
|
||||
return api.get<MailMessageHeaderDto[]>(`/tasks/${taskId}/mails`)
|
||||
}
|
||||
|
||||
// ─── Pièces jointes ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Télécharge une pièce jointe et retourne le Blob + headers.
|
||||
* Content-Disposition: attachment est géré côté backend (jamais inline).
|
||||
* @param downloadId - Identifiant opaque retourné dans MailAttachmentDto.downloadId
|
||||
*/
|
||||
async function downloadAttachment(
|
||||
downloadId: string,
|
||||
): Promise<{ data: Blob; headers: Headers }> {
|
||||
return api.getBlob(`/mail/attachments/${downloadId}`)
|
||||
}
|
||||
|
||||
// ─── Synchronisation ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Déclenche une synchronisation IMAP asynchrone via Symfony Messenger.
|
||||
* Retourne immédiatement ({ dispatched: true }) — la sync se fait en arrière-plan.
|
||||
*/
|
||||
async function triggerSync(): Promise<MailSyncResultDto> {
|
||||
return api.post<MailSyncResultDto>('/mail/sync', {}, {
|
||||
toastSuccessKey: 'mail.sync.dispatched',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
// Config
|
||||
getConfiguration,
|
||||
updateConfiguration,
|
||||
testConfiguration,
|
||||
// Dossiers
|
||||
listFolders,
|
||||
// Messages
|
||||
listMessages,
|
||||
getMessage,
|
||||
// Actions
|
||||
markRead,
|
||||
markFlagged,
|
||||
// Tâches
|
||||
createTaskFromMail,
|
||||
linkTask,
|
||||
unlinkTask,
|
||||
listMailsForTask,
|
||||
// Pièces jointes
|
||||
downloadAttachment,
|
||||
// Sync
|
||||
triggerSync,
|
||||
}
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type {
|
||||
MailFolderDto,
|
||||
MailMessageHeaderDto,
|
||||
MailMessageDetailDto,
|
||||
} from '~/services/dto/mail'
|
||||
import { useMailService } from '~/services/mail'
|
||||
|
||||
const POLL_INTERVAL_MS = 30 * 1000 // 30 secondes
|
||||
|
||||
export const useMailStore = defineStore('mail', () => {
|
||||
// ─── State ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Liste plate des dossiers (reçue de l'API) */
|
||||
const folders = ref<MailFolderDto[]>([])
|
||||
|
||||
/** Chemin du dossier actuellement sélectionné */
|
||||
const selectedFolderPath = ref<string | null>(null)
|
||||
|
||||
/** Messages du dossier sélectionné (accumulés pour infinite scroll) */
|
||||
const messages = ref<MailMessageHeaderDto[]>([])
|
||||
|
||||
/** Cursor de pagination pour la page suivante (null = plus de données) */
|
||||
const messagesCursor = ref<string | null>(null)
|
||||
|
||||
/** Chargement en cours (messages) */
|
||||
const messagesLoading = ref(false)
|
||||
|
||||
/** ID du message sélectionné pour lecture */
|
||||
const selectedMessageId = ref<number | null>(null)
|
||||
|
||||
/** Détail complet du message sélectionné (body + PJ) */
|
||||
const selectedMessageDetail = ref<MailMessageDetailDto | null>(null)
|
||||
|
||||
/** Chargement du détail en cours */
|
||||
const detailLoading = ref(false)
|
||||
|
||||
/** Sync IMAP en cours (déclenchée manuellement) */
|
||||
const syncing = ref(false)
|
||||
|
||||
/** Nombre total de messages non lus (toutes boîtes confondues) */
|
||||
const globalUnreadCount = ref(0)
|
||||
|
||||
/** Erreur courante (null si aucune) */
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// ─── Getters ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Nombre de non-lus dans INBOX uniquement (utilisé dans la sidebar).
|
||||
*/
|
||||
const inboxUnread = computed(() => {
|
||||
const inbox = folders.value.find(
|
||||
(f) => f.path === 'INBOX' || f.path.toUpperCase() === 'INBOX',
|
||||
)
|
||||
return inbox?.unreadCount ?? 0
|
||||
})
|
||||
|
||||
/**
|
||||
* Construit l'arbre de dossiers depuis la liste plate.
|
||||
* Les dossiers sans parentPath sont à la racine.
|
||||
* Les enfants sont triés alphabétiquement par displayName.
|
||||
*/
|
||||
const folderTree = computed((): MailFolderDto[] => {
|
||||
const map = new Map<string, MailFolderDto>()
|
||||
const roots: MailFolderDto[] = []
|
||||
|
||||
// Initialiser chaque dossier avec children vide
|
||||
folders.value.forEach((folder) => {
|
||||
map.set(folder.path, { ...folder, children: [] })
|
||||
})
|
||||
|
||||
// Construire l'arbre
|
||||
map.forEach((folder) => {
|
||||
if (folder.parentPath && map.has(folder.parentPath)) {
|
||||
const parent = map.get(folder.parentPath)!
|
||||
parent.children = parent.children ?? []
|
||||
parent.children.push(folder)
|
||||
} else {
|
||||
roots.push(folder)
|
||||
}
|
||||
})
|
||||
|
||||
// Trier les enfants alphabétiquement
|
||||
function sortChildren(nodes: MailFolderDto[]): MailFolderDto[] {
|
||||
return nodes
|
||||
.map((n) => ({
|
||||
...n,
|
||||
children: n.children ? sortChildren(n.children) : undefined,
|
||||
}))
|
||||
.sort((a, b) => a.displayName.localeCompare(b.displayName, 'fr'))
|
||||
}
|
||||
|
||||
return sortChildren(roots)
|
||||
})
|
||||
|
||||
/**
|
||||
* Indique si le cursor de pagination est disponible (plus de messages à charger).
|
||||
*/
|
||||
const hasMoreMessages = computed(() => messagesCursor.value !== null)
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Charge la liste des dossiers depuis l'API et met à jour globalUnreadCount.
|
||||
*/
|
||||
async function fetchFolders(): Promise<void> {
|
||||
const service = useMailService()
|
||||
try {
|
||||
folders.value = await service.listFolders()
|
||||
globalUnreadCount.value = folders.value.reduce(
|
||||
(sum, f) => sum + f.unreadCount,
|
||||
0,
|
||||
)
|
||||
} catch {
|
||||
// Silently ignore polling errors (ne pas interrompre l'UX)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge les messages du dossier sélectionné.
|
||||
* @param append - Si true, ajoute à la liste existante (infinite scroll). Si false, remplace.
|
||||
*/
|
||||
async function fetchMessages(append = false): Promise<void> {
|
||||
if (!selectedFolderPath.value) return
|
||||
if (messagesLoading.value) return
|
||||
|
||||
messagesLoading.value = true
|
||||
error.value = null
|
||||
|
||||
const service = useMailService()
|
||||
try {
|
||||
const cursor = append ? (messagesCursor.value ?? undefined) : undefined
|
||||
const page = await service.listMessages(selectedFolderPath.value, cursor)
|
||||
|
||||
if (append) {
|
||||
messages.value = [...messages.value, ...page.items]
|
||||
} else {
|
||||
messages.value = page.items
|
||||
}
|
||||
messagesCursor.value = page.nextCursor
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Erreur lors du chargement des messages.'
|
||||
} finally {
|
||||
messagesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sélectionne un dossier et charge ses messages (reset de la pagination).
|
||||
* @param path - Chemin du dossier (ex: "INBOX")
|
||||
*/
|
||||
async function selectFolder(path: string): Promise<void> {
|
||||
if (selectedFolderPath.value === path) return
|
||||
selectedFolderPath.value = path
|
||||
messages.value = []
|
||||
messagesCursor.value = null
|
||||
selectedMessageId.value = null
|
||||
selectedMessageDetail.value = null
|
||||
await fetchMessages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque un message comme lu ou non-lu.
|
||||
* Met à jour le state local (messages + detail) sans refetch.
|
||||
*/
|
||||
async function markRead(id: number, read: boolean): Promise<void> {
|
||||
const service = useMailService()
|
||||
const updated = await service.markRead(id, read)
|
||||
|
||||
// Mise à jour optimiste dans la liste
|
||||
const idx = messages.value.findIndex((m) => m.id === id)
|
||||
if (idx !== -1) {
|
||||
messages.value[idx] = { ...messages.value[idx], isRead: updated.isRead }
|
||||
}
|
||||
|
||||
// Mise à jour dans le détail si ouvert
|
||||
if (selectedMessageDetail.value?.header.id === id) {
|
||||
selectedMessageDetail.value = {
|
||||
...selectedMessageDetail.value,
|
||||
header: { ...selectedMessageDetail.value.header, isRead: updated.isRead },
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour le compteur du dossier
|
||||
await _refreshFolderUnreadCount()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sélectionne un message et charge son détail complet (body + PJ).
|
||||
* Marque automatiquement le message comme lu si ce n'est pas déjà le cas.
|
||||
* @param id - ID BDD du message
|
||||
*/
|
||||
async function selectMessage(id: number): Promise<void> {
|
||||
if (selectedMessageId.value === id) return
|
||||
selectedMessageId.value = id
|
||||
selectedMessageDetail.value = null
|
||||
detailLoading.value = true
|
||||
|
||||
const service = useMailService()
|
||||
try {
|
||||
const detail = await service.getMessage(id)
|
||||
selectedMessageDetail.value = detail
|
||||
|
||||
// Auto-mark as read si nécessaire
|
||||
if (!detail.header.isRead) {
|
||||
await markRead(id, true)
|
||||
}
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque un message comme étoilé ou non-étoilé.
|
||||
* Met à jour le state local sans refetch.
|
||||
*/
|
||||
async function markFlagged(id: number, flagged: boolean): Promise<void> {
|
||||
const service = useMailService()
|
||||
const updated = await service.markFlagged(id, flagged)
|
||||
|
||||
const idx = messages.value.findIndex((m) => m.id === id)
|
||||
if (idx !== -1) {
|
||||
messages.value[idx] = { ...messages.value[idx], isFlagged: updated.isFlagged }
|
||||
}
|
||||
|
||||
if (selectedMessageDetail.value?.header.id === id) {
|
||||
selectedMessageDetail.value = {
|
||||
...selectedMessageDetail.value,
|
||||
header: { ...selectedMessageDetail.value.header, isFlagged: updated.isFlagged },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Déclenche une synchronisation IMAP asynchrone.
|
||||
* Recharge les dossiers après 2s pour refléter les nouveaux messages.
|
||||
*/
|
||||
async function triggerSync(): Promise<void> {
|
||||
if (syncing.value) return
|
||||
syncing.value = true
|
||||
const service = useMailService()
|
||||
try {
|
||||
await service.triggerSync()
|
||||
// Laisser le temps au handler Messenger de traiter
|
||||
setTimeout(async () => {
|
||||
await fetchFolders()
|
||||
if (selectedFolderPath.value) {
|
||||
await fetchMessages(false)
|
||||
}
|
||||
syncing.value = false
|
||||
}, 2000)
|
||||
} catch {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrête le polling. À appeler au logout.
|
||||
*/
|
||||
function stopPolling(): void {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre le polling toutes les 30s pour mettre à jour globalUnreadCount.
|
||||
* À appeler dans app.vue ou le layout default au login.
|
||||
* Idempotent : un seul timer actif à la fois.
|
||||
*/
|
||||
function startPolling(): void {
|
||||
if (pollTimer) return
|
||||
fetchFolders() // Charge immédiatement
|
||||
pollTimer = setInterval(fetchFolders, POLL_INTERVAL_MS)
|
||||
|
||||
// Cleanup automatique si le scope du store est détruit
|
||||
if (getCurrentScope()) {
|
||||
onScopeDispose(stopPolling)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rafraîchit les compteurs non-lus du dossier actuel depuis l'API.
|
||||
* Usage interne — appelé après markRead.
|
||||
*/
|
||||
async function _refreshFolderUnreadCount(): Promise<void> {
|
||||
const service = useMailService()
|
||||
try {
|
||||
const updatedFolders = await service.listFolders()
|
||||
folders.value = updatedFolders
|
||||
globalUnreadCount.value = updatedFolders.reduce(
|
||||
(sum, f) => sum + f.unreadCount,
|
||||
0,
|
||||
)
|
||||
} catch {
|
||||
// Silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State (readonly pour les consommateurs)
|
||||
folders: readonly(folders),
|
||||
selectedFolderPath: readonly(selectedFolderPath),
|
||||
messages: readonly(messages),
|
||||
messagesCursor: readonly(messagesCursor),
|
||||
messagesLoading: readonly(messagesLoading),
|
||||
selectedMessageId: readonly(selectedMessageId),
|
||||
selectedMessageDetail: readonly(selectedMessageDetail),
|
||||
detailLoading: readonly(detailLoading),
|
||||
syncing: readonly(syncing),
|
||||
globalUnreadCount: readonly(globalUnreadCount),
|
||||
error: readonly(error),
|
||||
// Getters
|
||||
inboxUnread,
|
||||
folderTree,
|
||||
hasMoreMessages,
|
||||
// Actions
|
||||
fetchFolders,
|
||||
selectFolder,
|
||||
fetchMessages,
|
||||
selectMessage,
|
||||
markRead,
|
||||
markFlagged,
|
||||
triggerSync,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
}
|
||||
})
|
||||
@@ -1,160 +0,0 @@
|
||||
import DOMPurify, { type Config as DOMPurifyConfig } from 'dompurify'
|
||||
|
||||
/**
|
||||
* Options de sanitization du corps HTML d'un mail.
|
||||
*/
|
||||
export type SanitizeMailHtmlOptions = {
|
||||
/**
|
||||
* Si true, les images distantes (http/https) sont affichées directement.
|
||||
* Par défaut false — les images distantes sont remplacées par un placeholder
|
||||
* cliquable pour éviter le tracking par pixel.
|
||||
*/
|
||||
allowImages?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration DOMPurify bloquante pour les corps de mail.
|
||||
* - Bloque les balises dangereuses : script, iframe, object, embed, style, link, meta, form, input
|
||||
* - Bloque les attributs événements (on*) et les URI javascript:
|
||||
* - Autorise les URI data: uniquement pour les images (PNG/JPEG/GIF/WEBP) — images inline CID
|
||||
*/
|
||||
const DOMPURIFY_CONFIG: DOMPurifyConfig = {
|
||||
FORBID_TAGS: [
|
||||
'script',
|
||||
'iframe',
|
||||
'object',
|
||||
'embed',
|
||||
'style',
|
||||
'link',
|
||||
'meta',
|
||||
'form',
|
||||
'input',
|
||||
'button',
|
||||
'textarea',
|
||||
'select',
|
||||
'base',
|
||||
'applet',
|
||||
],
|
||||
FORBID_ATTR: [
|
||||
'onerror',
|
||||
'onload',
|
||||
'onclick',
|
||||
'onmouseover',
|
||||
'onmouseout',
|
||||
'onmouseenter',
|
||||
'onmouseleave',
|
||||
'onfocus',
|
||||
'onblur',
|
||||
'onchange',
|
||||
'onsubmit',
|
||||
'onreset',
|
||||
'onkeydown',
|
||||
'onkeyup',
|
||||
'onkeypress',
|
||||
'ondblclick',
|
||||
'oncontextmenu',
|
||||
'onwheel',
|
||||
'ondrag',
|
||||
'ondrop',
|
||||
'oncopy',
|
||||
'oncut',
|
||||
'onpaste',
|
||||
'action',
|
||||
'formaction',
|
||||
'xlink:href',
|
||||
],
|
||||
ALLOWED_URI_REGEXP: /^(?:https?|mailto|tel|cid|data:image\/(?:png|jpeg|gif|webp)(?:;base64,)?)(?::|$)/i,
|
||||
FORCE_BODY: true,
|
||||
WHOLE_DOCUMENT: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Remplace les balises <img> avec src http(s):// par un bouton placeholder.
|
||||
* Le src original est stocké en data-mail-image-src pour permettre l'affichage
|
||||
* à la demande de l'utilisateur (Phase 5 — MailMessageViewer).
|
||||
*/
|
||||
function replaceRemoteImages(html: string): string {
|
||||
// Utiliser un DOMParser côté client uniquement (SSR-safe : le guard process.client
|
||||
// est géré par l'appelant dans un composant Vue — ce helper ne tourne que client-side)
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
const images = doc.querySelectorAll('img')
|
||||
|
||||
images.forEach((img) => {
|
||||
const src = img.getAttribute('src') ?? ''
|
||||
const isRemote = /^https?:\/\//i.test(src)
|
||||
if (!isRemote) return
|
||||
|
||||
// Remplacer par un span cliquable (pas de <button> — DOMPurify le forbid)
|
||||
const placeholder = doc.createElement('span')
|
||||
placeholder.setAttribute('data-mail-image-src', src)
|
||||
placeholder.setAttribute('data-mail-image-placeholder', 'true')
|
||||
placeholder.setAttribute('title', src)
|
||||
placeholder.style.cssText = [
|
||||
'display: inline-flex',
|
||||
'align-items: center',
|
||||
'gap: 4px',
|
||||
'padding: 2px 6px',
|
||||
'border: 1px solid #d1d5db',
|
||||
'border-radius: 4px',
|
||||
'background: #f9fafb',
|
||||
'color: #6b7280',
|
||||
'font-size: 12px',
|
||||
'cursor: pointer',
|
||||
'user-select: none',
|
||||
].join(';')
|
||||
placeholder.textContent = '[Image distante — cliquer pour afficher]'
|
||||
|
||||
img.replaceWith(placeholder)
|
||||
})
|
||||
|
||||
return doc.body.innerHTML
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize le HTML brut d'un corps de mail.
|
||||
*
|
||||
* - Bloque tous les vecteurs XSS connus (scripts, événements inline, iframes…)
|
||||
* - Par défaut, remplace les images distantes par un placeholder anti-tracking
|
||||
* - Utiliser allowImages: true uniquement si l'utilisateur a explicitement cliqué
|
||||
* "Afficher les images" dans le lecteur de mail
|
||||
*
|
||||
* IMPORTANT : Cette fonction requiert un environnement navigateur (DOMParser, DOMPurify).
|
||||
* Ne pas appeler côté SSR — toujours dans un composant Vue avec `onMounted` ou dans
|
||||
* un computed côté client uniquement (`import.meta.client`).
|
||||
*
|
||||
* @param rawHtml - HTML brut tel que reçu de l'API backend
|
||||
* @param options - Options de sanitization
|
||||
* @returns HTML sanitizé, sûr pour injection via v-html
|
||||
*/
|
||||
export function sanitizeMailHtml(
|
||||
rawHtml: string,
|
||||
options: SanitizeMailHtmlOptions = {},
|
||||
): string {
|
||||
if (!rawHtml || rawHtml.trim() === '') return ''
|
||||
|
||||
// Étape 1 : DOMPurify — supprime tous les vecteurs dangereux
|
||||
const sanitized = DOMPurify.sanitize(rawHtml, DOMPURIFY_CONFIG) as string
|
||||
|
||||
// Étape 2 : Remplacement images distantes (anti-tracking)
|
||||
if (!options.allowImages) {
|
||||
return replaceRemoteImages(sanitized)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un élément HTML est un placeholder d'image généré par sanitizeMailHtml.
|
||||
* Utile dans MailMessageViewer pour gérer le clic "Afficher l'image".
|
||||
*/
|
||||
export function isMailImagePlaceholder(el: HTMLElement): boolean {
|
||||
return el.hasAttribute('data-mail-image-placeholder')
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le src original d'un placeholder d'image.
|
||||
*/
|
||||
export function getMailImageSrc(el: HTMLElement): string | null {
|
||||
return el.getAttribute('data-mail-image-src')
|
||||
}
|
||||
@@ -15,13 +15,6 @@ JWT_COOKIE_SAMESITE=lax
|
||||
JWT_TOKEN_TTL=86400
|
||||
JWT_COOKIE_TTL=86400
|
||||
|
||||
# Mail (intégration IMAP/SMTP)
|
||||
# Clé hex 32 bytes chiffrant les credentials mail stockés en base.
|
||||
# Générer : php -r "echo bin2hex(random_bytes(32));" — doit rester STABLE.
|
||||
ENCRYPTION_KEY=change-me
|
||||
# Store de verrous Symfony pour la sync mail (anti-chevauchement du cron).
|
||||
LOCK_DSN=flock
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
|
||||
|
||||
|
||||
6
makefile
6
makefile
@@ -122,11 +122,5 @@ php-cs-fixer-allow-risky:
|
||||
test:
|
||||
$(EXEC_PHP) php -d memory_limit="512M" vendor/bin/phpunit $(FILES)
|
||||
|
||||
## Synchronise la boîte mail IMAP vers la base locale (cron OS toutes les 10 min)
|
||||
## Passer FOLDER=INBOX pour cibler un seul dossier. Ex: make mail-sync FOLDER=INBOX
|
||||
## Passer DRYRUN=1 pour simuler sans écrire. Ex: make mail-sync DRYRUN=1
|
||||
mail-sync:
|
||||
$(SYMFONY_CONSOLE) app:mail:sync $(if $(FOLDER),--folder=$(FOLDER),) $(if $(DRYRUN),--dry-run,)
|
||||
|
||||
wait:
|
||||
sleep 10
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260519211723 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Mail integration: create mail_configuration, mail_folder, mail_message, task_mail_link tables';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE mail_configuration (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
protocol VARCHAR(10) NOT NULL,
|
||||
imap_host VARCHAR(255) DEFAULT NULL,
|
||||
imap_port INT NOT NULL,
|
||||
imap_encryption VARCHAR(10) NOT NULL,
|
||||
smtp_host VARCHAR(255) DEFAULT NULL,
|
||||
smtp_port INT NOT NULL,
|
||||
smtp_encryption VARCHAR(10) NOT NULL,
|
||||
username VARCHAR(255) DEFAULT NULL,
|
||||
encrypted_password TEXT DEFAULT NULL,
|
||||
sent_folder_path VARCHAR(255) NOT NULL,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE mail_folder (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
path VARCHAR(500) NOT NULL,
|
||||
display_name VARCHAR(255) NOT NULL,
|
||||
parent_path VARCHAR(500) DEFAULT NULL,
|
||||
unread_count INT NOT NULL,
|
||||
total_count INT NOT NULL,
|
||||
last_synced_at TIMESTAMP(0) WITH TIME ZONE DEFAULT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_319BB6A6B548B0F ON mail_folder (path)');
|
||||
$this->addSql('CREATE INDEX idx_mail_folder_parent_path ON mail_folder (parent_path)');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE mail_message (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
message_id VARCHAR(500) NOT NULL,
|
||||
folder_id INT NOT NULL,
|
||||
uid INT NOT NULL,
|
||||
subject VARCHAR(500) DEFAULT NULL,
|
||||
from_address VARCHAR(255) NOT NULL,
|
||||
from_name VARCHAR(255) DEFAULT NULL,
|
||||
to_addresses JSON NOT NULL,
|
||||
cc_addresses JSON DEFAULT NULL,
|
||||
sent_at TIMESTAMP(0) WITH TIME ZONE NOT NULL,
|
||||
is_read BOOLEAN NOT NULL,
|
||||
is_flagged BOOLEAN NOT NULL,
|
||||
has_attachments BOOLEAN NOT NULL,
|
||||
snippet TEXT DEFAULT NULL,
|
||||
synced_at TIMESTAMP(0) WITH TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE UNIQUE INDEX UNIQ_6C00B110537A1329 ON mail_message (message_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX uq_mail_message_folder_uid ON mail_message (folder_id, uid)');
|
||||
$this->addSql('CREATE INDEX IDX_6C00B110162CB942 ON mail_message (folder_id)');
|
||||
$this->addSql('CREATE INDEX idx_mail_message_sent_at ON mail_message (sent_at)');
|
||||
$this->addSql('CREATE INDEX idx_mail_message_is_read ON mail_message (is_read)');
|
||||
$this->addSql('ALTER TABLE mail_message ADD CONSTRAINT FK_6C00B110162CB942 FOREIGN KEY (folder_id) REFERENCES mail_folder (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE task_mail_link (
|
||||
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
task_id INT NOT NULL,
|
||||
mail_message_id INT NOT NULL,
|
||||
linked_at TIMESTAMP(0) WITH TIME ZONE NOT NULL,
|
||||
linked_by_id INT DEFAULT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE UNIQUE INDEX uq_task_mail_link ON task_mail_link (task_id, mail_message_id)');
|
||||
$this->addSql('CREATE INDEX IDX_E4FDC7C98DB60186 ON task_mail_link (task_id)');
|
||||
$this->addSql('CREATE INDEX IDX_E4FDC7C987B9F9D5 ON task_mail_link (mail_message_id)');
|
||||
$this->addSql('CREATE INDEX IDX_E4FDC7C91AE3CFF3 ON task_mail_link (linked_by_id)');
|
||||
$this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT FK_E4FDC7C98DB60186 FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT FK_E4FDC7C987B9F9D5 FOREIGN KEY (mail_message_id) REFERENCES mail_message (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE task_mail_link ADD CONSTRAINT FK_E4FDC7C91AE3CFF3 FOREIGN KEY (linked_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT FK_E4FDC7C98DB60186');
|
||||
$this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT FK_E4FDC7C987B9F9D5');
|
||||
$this->addSql('ALTER TABLE task_mail_link DROP CONSTRAINT FK_E4FDC7C91AE3CFF3');
|
||||
$this->addSql('DROP TABLE task_mail_link');
|
||||
|
||||
$this->addSql('ALTER TABLE mail_message DROP CONSTRAINT FK_6C00B110162CB942');
|
||||
$this->addSql('DROP TABLE mail_message');
|
||||
|
||||
$this->addSql('DROP TABLE mail_folder');
|
||||
|
||||
$this->addSql('DROP TABLE mail_configuration');
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Cree la table messenger_messages pour le transport async Symfony Messenger.
|
||||
*/
|
||||
final class Version20260519220000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Cree la table messenger_messages pour le transport async Doctrine de Symfony Messenger.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS messenger_messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
body TEXT NOT NULL,
|
||||
headers TEXT NOT NULL,
|
||||
queue_name VARCHAR(190) NOT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE OR REPLACE FUNCTION notify_messenger_messages() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('messenger_messages', NEW.queue_name::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
SQL);
|
||||
|
||||
$this->addSql('DROP TRIGGER IF EXISTS notify_trigger ON messenger_messages;');
|
||||
$this->addSql('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON messenger_messages FOR EACH ROW EXECUTE PROCEDURE notify_messenger_messages();');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS messenger_messages');
|
||||
$this->addSql('DROP FUNCTION IF EXISTS notify_messenger_messages()');
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260520061736 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'mail_message.message_id: drop global UNIQUE (un même Message-ID existe dans plusieurs dossiers IMAP), conserver un index simple';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX uniq_6c00b110537a1329');
|
||||
$this->addSql('CREATE INDEX idx_mail_message_message_id ON mail_message (message_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX idx_mail_message_message_id');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_6c00b110537a1329 ON mail_message (message_id)');
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260521094948 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Remet les couleurs classiques sur les statuts du workflow Standard (derive data prod).';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$map = [
|
||||
'todo' => '#222783',
|
||||
'in_progress' => '#4A90D9',
|
||||
'blocked' => '#C62828',
|
||||
'review' => '#FF8F00',
|
||||
'done' => '#26A69A',
|
||||
];
|
||||
foreach ($map as $category => $hex) {
|
||||
$this->addSql(
|
||||
"UPDATE task_status SET color = '".$hex."' WHERE category = '".$category."' AND workflow_id = (SELECT id FROM workflow WHERE name = 'Standard' ORDER BY id ASC LIMIT 1)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->throwIrreversibleMigration('Correction de couleurs non reversible.');
|
||||
}
|
||||
}
|
||||
@@ -15,19 +15,6 @@
|
||||
<ini name="error_reporting" value="-1" />
|
||||
<server name="APP_ENV" value="test" force="true" />
|
||||
<server name="SHELL_VERBOSITY" value="-1" />
|
||||
<server name="KERNEL_CLASS" value="App\Kernel" />
|
||||
|
||||
<!-- ###+ symfony/lock ### -->
|
||||
<!-- Choose one of the stores below -->
|
||||
<!-- postgresql+advisory://db_user:db_password@localhost/db_name -->
|
||||
<env name="LOCK_DSN" value="flock"/>
|
||||
<!-- ###- symfony/lock ### -->
|
||||
|
||||
<!-- ###+ symfony/messenger ### -->
|
||||
<env name="MESSENGER_TRANSPORT_DSN" value="doctrine://default?auto_setup=0"/>
|
||||
<!-- ###- symfony/messenger ### -->
|
||||
|
||||
<env name="ENCRYPTION_KEY" value="ccd250183ea853179562d458e645585f3d46ddebb0701743236196f60fc1a0b8"/>
|
||||
</php>
|
||||
|
||||
<testsuites>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use App\State\Mail\MailSettingsProcessor;
|
||||
use App\State\Mail\MailSettingsProvider;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/mail/configuration',
|
||||
normalizationContext: ['groups' => ['mail_settings:read']],
|
||||
provider: MailSettingsProvider::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
new Patch(
|
||||
uriTemplate: '/mail/configuration',
|
||||
denormalizationContext: ['groups' => ['mail_settings:write']],
|
||||
normalizationContext: ['groups' => ['mail_settings:read']],
|
||||
provider: MailSettingsProvider::class,
|
||||
processor: MailSettingsProcessor::class,
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
),
|
||||
],
|
||||
)]
|
||||
final class MailSettings
|
||||
{
|
||||
#[Groups(['mail_settings:read', 'mail_settings:write'])]
|
||||
public ?string $protocol = null;
|
||||
|
||||
#[Groups(['mail_settings:read', 'mail_settings:write'])]
|
||||
public ?string $imapHost = null;
|
||||
|
||||
#[Groups(['mail_settings:read', 'mail_settings:write'])]
|
||||
public ?int $imapPort = null;
|
||||
|
||||
#[Groups(['mail_settings:read', 'mail_settings:write'])]
|
||||
public ?string $imapEncryption = null;
|
||||
|
||||
#[Groups(['mail_settings:read', 'mail_settings:write'])]
|
||||
public ?string $smtpHost = null;
|
||||
|
||||
#[Groups(['mail_settings:read', 'mail_settings:write'])]
|
||||
public ?int $smtpPort = null;
|
||||
|
||||
#[Groups(['mail_settings:read', 'mail_settings:write'])]
|
||||
public ?string $smtpEncryption = null;
|
||||
|
||||
#[Groups(['mail_settings:read', 'mail_settings:write'])]
|
||||
public ?string $username = null;
|
||||
|
||||
#[Groups(['mail_settings:write'])]
|
||||
public ?string $password = null;
|
||||
|
||||
#[Groups(['mail_settings:read', 'mail_settings:write'])]
|
||||
public ?string $sentFolderPath = null;
|
||||
|
||||
#[Groups(['mail_settings:read', 'mail_settings:write'])]
|
||||
public bool $enabled = false;
|
||||
|
||||
#[Groups(['mail_settings:read'])]
|
||||
public bool $hasPassword = false;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\MailConfigurationRepository;
|
||||
use App\Repository\MailFolderRepository;
|
||||
use App\Service\MailSyncService;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:mail:sync',
|
||||
description: 'Synchronise la boîte mail partagée OVH (IMAP) vers la base locale',
|
||||
)]
|
||||
final class MailSyncCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailSyncService $mailSyncService,
|
||||
private readonly MailConfigurationRepository $configRepository,
|
||||
private readonly MailFolderRepository $folderRepository,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption(
|
||||
'folder',
|
||||
null,
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
'Synchronise uniquement le dossier spécifié (ex: INBOX)',
|
||||
)
|
||||
->addOption(
|
||||
'dry-run',
|
||||
null,
|
||||
InputOption::VALUE_NONE,
|
||||
'Simule la synchronisation sans écrire en base (lecture IMAP uniquement)',
|
||||
)
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$config = $this->configRepository->findSingleton();
|
||||
|
||||
if (null === $config || !$config->isEnabled()) {
|
||||
$io->info('Mail config disabled, skipping.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$isDryRun = (bool) $input->getOption('dry-run');
|
||||
$folderPath = $input->getOption('folder');
|
||||
|
||||
if ($isDryRun) {
|
||||
$io->note('Mode --dry-run activé : aucune écriture en base.');
|
||||
$io->success('Dry-run terminé — config IMAP active, aucune sync exécutée.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->text('Démarrage de la synchronisation mail...');
|
||||
$startTime = microtime(true);
|
||||
|
||||
if (null !== $folderPath) {
|
||||
$folder = $this->folderRepository->findByPath((string) $folderPath);
|
||||
|
||||
if (null === $folder) {
|
||||
$io->error(sprintf('Dossier "%s" introuvable en base — lance une sync complète au moins une fois.', $folderPath));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->text(sprintf('Synchronisation du dossier : %s', $folderPath));
|
||||
$report = $this->mailSyncService->syncFolder($folder);
|
||||
} else {
|
||||
$report = $this->mailSyncService->syncAll();
|
||||
}
|
||||
|
||||
$elapsed = round(microtime(true) - $startTime, 2);
|
||||
|
||||
$io->success(sprintf(
|
||||
'Sync terminée en %.1fs : %d créés, %d mis à jour, %d supprimés, %d dossiers scannés.',
|
||||
$elapsed,
|
||||
$report->createdCount,
|
||||
$report->updatedCount,
|
||||
$report->deletedCount,
|
||||
$report->foldersScanned,
|
||||
));
|
||||
|
||||
if ([] !== $report->errors) {
|
||||
$io->warning(sprintf('%d erreur(s) :', count($report->errors)));
|
||||
|
||||
foreach ($report->errors as $error) {
|
||||
$io->text(' - '.$error);
|
||||
}
|
||||
}
|
||||
|
||||
return [] === $report->errors ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Mail\Exception\MailProviderException;
|
||||
use App\Mail\MailProviderInterface;
|
||||
use App\Repository\MailMessageRepository;
|
||||
use App\Security\MailAccessChecker;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/api/mail/attachments/{downloadId}', name: 'mail_attachment_download', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class MailAttachmentDownloadController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailMessageRepository $messageRepository,
|
||||
private readonly MailProviderInterface $mailProvider,
|
||||
private readonly MailAccessChecker $accessChecker,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $downloadId): Response
|
||||
{
|
||||
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
||||
|
||||
$decoded = base64_decode(strtr($downloadId, '-_', '+/'), true);
|
||||
if (false === $decoded || !str_contains($decoded, ':')) {
|
||||
throw new BadRequestHttpException('Invalid attachment ID format');
|
||||
}
|
||||
|
||||
[$messageDbIdStr, $partNumber] = explode(':', $decoded, 2);
|
||||
$messageDbId = (int) $messageDbIdStr;
|
||||
|
||||
$message = $this->messageRepository->find($messageDbId);
|
||||
if (null === $message) {
|
||||
throw new NotFoundHttpException('Message not found');
|
||||
}
|
||||
|
||||
try {
|
||||
$detail = $this->mailProvider->fetchMessage(
|
||||
$message->getFolder()->getPath(),
|
||||
$message->getUid()
|
||||
);
|
||||
} catch (MailProviderException) {
|
||||
throw new NotFoundHttpException('Could not fetch message from IMAP server');
|
||||
}
|
||||
|
||||
$targetAttachment = null;
|
||||
foreach ($detail->attachments as $att) {
|
||||
if ($att->partNumber === $partNumber) {
|
||||
$targetAttachment = $att;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $targetAttachment) {
|
||||
throw new NotFoundHttpException(sprintf('Attachment part "%s" not found', $partNumber));
|
||||
}
|
||||
|
||||
try {
|
||||
$content = $this->mailProvider->fetchAttachment(
|
||||
$message->getFolder()->getPath(),
|
||||
$message->getUid(),
|
||||
$partNumber
|
||||
);
|
||||
} catch (MailProviderException) {
|
||||
throw new NotFoundHttpException('Could not fetch attachment content');
|
||||
}
|
||||
|
||||
$filename = basename($targetAttachment->filename);
|
||||
if ('' === $filename || '.' === $filename) {
|
||||
$filename = 'attachment';
|
||||
}
|
||||
|
||||
$response = new Response($content);
|
||||
$response->headers->set('Content-Type', $targetAttachment->mimeType);
|
||||
$response->headers->set(
|
||||
'Content-Disposition',
|
||||
sprintf('attachment; filename="%s"', addslashes($filename))
|
||||
);
|
||||
$response->headers->set('Content-Length', (string) strlen($content));
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskGroup;
|
||||
use App\Entity\TaskMailLink;
|
||||
use App\Entity\TaskStatus;
|
||||
use App\Entity\User;
|
||||
use App\Repository\MailMessageRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Security\MailAccessChecker;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/api/mail/messages/{id}/create-task', name: 'mail_create_task', methods: ['POST'], priority: 1, requirements: ['id' => '\d+'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class MailCreateTaskController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailMessageRepository $messageRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly MailAccessChecker $accessChecker,
|
||||
private readonly TaskRepository $taskRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
||||
|
||||
$message = $this->messageRepository->find($id);
|
||||
if (null === $message) {
|
||||
throw new NotFoundHttpException('Message not found');
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$projectId = $body['projectId'] ?? null;
|
||||
|
||||
if (null === $projectId) {
|
||||
throw new UnprocessableEntityHttpException('projectId is required');
|
||||
}
|
||||
|
||||
$project = $this->em->getRepository(Project::class)->find($projectId);
|
||||
if (null === $project) {
|
||||
throw new NotFoundHttpException('Project not found');
|
||||
}
|
||||
|
||||
$title = $message->getSubject() ?? 'Mail sans sujet';
|
||||
if (mb_strlen($title) > 255) {
|
||||
$title = mb_substr($title, 0, 252).'...';
|
||||
}
|
||||
|
||||
$result = $this->em->wrapInTransaction(function () use ($project, $title, $body, $message) {
|
||||
$maxNumber = $this->taskRepository->findMaxNumberByProjectForUpdate($project);
|
||||
|
||||
$task = new Task();
|
||||
$task->setProject($project);
|
||||
$task->setTitle($title);
|
||||
$task->setNumber($maxNumber + 1);
|
||||
|
||||
if (isset($body['taskGroupId']) && null !== $body['taskGroupId']) {
|
||||
$taskGroup = $this->em->getRepository(TaskGroup::class)->find($body['taskGroupId']);
|
||||
if (null !== $taskGroup) {
|
||||
$task->setGroup($taskGroup);
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($body['assigneeId']) && null !== $body['assigneeId']) {
|
||||
$assignee = $this->em->getRepository(User::class)->find($body['assigneeId']);
|
||||
if (null !== $assignee) {
|
||||
$task->setAssignee($assignee);
|
||||
}
|
||||
}
|
||||
|
||||
// Statut : celui fourni, sinon le premier statut du workflow du projet (par position)
|
||||
$status = null;
|
||||
if (isset($body['statusId']) && null !== $body['statusId']) {
|
||||
$status = $this->em->getRepository(TaskStatus::class)->find($body['statusId']);
|
||||
}
|
||||
if (null === $status) {
|
||||
$status = $project->getWorkflow()?->getStatuses()->first() ?: null;
|
||||
}
|
||||
if (null !== $status) {
|
||||
$task->setStatus($status);
|
||||
}
|
||||
|
||||
$this->em->persist($task);
|
||||
|
||||
$link = new TaskMailLink();
|
||||
$link->setTask($task);
|
||||
$link->setMailMessage($message);
|
||||
$link->setLinkedAt(new DateTimeImmutable());
|
||||
$link->setLinkedBy($this->getUser());
|
||||
$this->em->persist($link);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $task;
|
||||
});
|
||||
|
||||
return $this->json([
|
||||
'taskId' => $result->getId(),
|
||||
'taskNumber' => $result->getNumber(),
|
||||
'taskTitle' => $result->getTitle(),
|
||||
'messageId' => $message->getId(),
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Repository\MailFolderRepository;
|
||||
use App\Security\MailAccessChecker;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/api/mail/folders', name: 'mail_folders_list', methods: ['GET'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class MailFoldersListController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailFolderRepository $folderRepository,
|
||||
private readonly MailAccessChecker $accessChecker,
|
||||
) {}
|
||||
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
||||
|
||||
$folders = $this->folderRepository->findAllOrderedByPath();
|
||||
|
||||
$data = array_map(static fn ($folder) => [
|
||||
'id' => $folder->getId(),
|
||||
'path' => $folder->getPath(),
|
||||
'displayName' => $folder->getDisplayName(),
|
||||
'parentPath' => $folder->getParentPath(),
|
||||
'unreadCount' => $folder->getUnreadCount(),
|
||||
'totalCount' => $folder->getTotalCount(),
|
||||
'lastSyncedAt' => $folder->getLastSyncedAt()?->format(DateTimeInterface::ATOM),
|
||||
], $folders);
|
||||
|
||||
return $this->json($data);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskMailLink;
|
||||
use App\Repository\MailMessageRepository;
|
||||
use App\Repository\TaskMailLinkRepository;
|
||||
use App\Security\MailAccessChecker;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/api/mail/messages/{id}/link-task', name: 'mail_link_task', methods: ['POST'], priority: 1, requirements: ['id' => '\d+'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class MailLinkTaskController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailMessageRepository $messageRepository,
|
||||
private readonly TaskMailLinkRepository $linkRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly MailAccessChecker $accessChecker,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
||||
|
||||
$message = $this->messageRepository->find($id);
|
||||
if (null === $message) {
|
||||
throw new NotFoundHttpException('Message not found');
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$taskId = $body['taskId'] ?? null;
|
||||
|
||||
if (null === $taskId) {
|
||||
throw new UnprocessableEntityHttpException('taskId is required');
|
||||
}
|
||||
|
||||
$task = $this->em->getRepository(Task::class)->find($taskId);
|
||||
if (null === $task) {
|
||||
throw new NotFoundHttpException('Task not found');
|
||||
}
|
||||
|
||||
$existing = $this->linkRepository->findByTaskAndMessage($task, $message);
|
||||
if (null !== $existing) {
|
||||
return $this->json(['message' => 'Already linked']);
|
||||
}
|
||||
|
||||
$link = new TaskMailLink();
|
||||
$link->setTask($task);
|
||||
$link->setMailMessage($message);
|
||||
$link->setLinkedAt(new DateTimeImmutable());
|
||||
$link->setLinkedBy($this->getUser());
|
||||
$this->em->persist($link);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->json(['linkId' => $link->getId(), 'taskId' => $task->getId(), 'messageId' => $message->getId()], 201);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Mail\Exception\MailProviderException;
|
||||
use App\Mail\MailProviderInterface;
|
||||
use App\Repository\MailMessageRepository;
|
||||
use App\Security\MailAccessChecker;
|
||||
use DateTimeInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/api/mail/messages/{id}', name: 'mail_message_detail', methods: ['GET'], priority: 1, requirements: ['id' => '\d+'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class MailMessageDetailController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailMessageRepository $messageRepository,
|
||||
private readonly MailProviderInterface $mailProvider,
|
||||
private readonly MailAccessChecker $accessChecker,
|
||||
private readonly CacheItemPoolInterface $cache,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): JsonResponse
|
||||
{
|
||||
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
||||
|
||||
$message = $this->messageRepository->find($id);
|
||||
if (null === $message) {
|
||||
throw new NotFoundHttpException('Message not found');
|
||||
}
|
||||
|
||||
$cacheKey = 'mail_body_'.md5($message->getMessageId());
|
||||
$item = $this->cache->getItem($cacheKey);
|
||||
|
||||
if (!$item->isHit()) {
|
||||
try {
|
||||
$detail = $this->mailProvider->fetchMessage(
|
||||
$message->getFolder()->getPath(),
|
||||
$message->getUid()
|
||||
);
|
||||
$item->set($detail);
|
||||
$item->expiresAfter(300);
|
||||
$this->cache->save($item);
|
||||
} catch (MailProviderException) {
|
||||
throw new ServiceUnavailableHttpException(null, 'IMAP unavailable: could not fetch message body');
|
||||
}
|
||||
}
|
||||
|
||||
$detail = $item->get();
|
||||
|
||||
$messageId = $message->getId();
|
||||
$attachments = array_map(static fn ($att) => [
|
||||
'partNumber' => $att->partNumber,
|
||||
'filename' => $att->filename,
|
||||
'mimeType' => $att->mimeType,
|
||||
'size' => $att->size,
|
||||
'downloadId' => rtrim(strtr(base64_encode($messageId.':'.$att->partNumber), '+/', '-_'), '='),
|
||||
], $detail->attachments);
|
||||
|
||||
return $this->json([
|
||||
'id' => $message->getId(),
|
||||
'messageId' => $message->getMessageId(),
|
||||
'uid' => $message->getUid(),
|
||||
'folderPath' => $message->getFolder()->getPath(),
|
||||
'subject' => $detail->header->subject,
|
||||
'fromAddress' => $detail->header->fromAddress,
|
||||
'fromName' => $detail->header->fromName,
|
||||
'toAddresses' => $detail->header->toAddresses,
|
||||
'ccAddresses' => $detail->header->ccAddresses,
|
||||
'sentAt' => $detail->header->sentAt->format(DateTimeInterface::ATOM),
|
||||
'isRead' => $message->isRead(),
|
||||
'isFlagged' => $message->isFlagged(),
|
||||
'hasAttachments' => $message->hasAttachments(),
|
||||
'bodyHtml' => $detail->bodyHtml,
|
||||
'bodyText' => $detail->bodyText,
|
||||
'attachments' => $attachments,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Mail\Exception\MailProviderException;
|
||||
use App\Mail\MailProviderInterface;
|
||||
use App\Repository\MailMessageRepository;
|
||||
use App\Security\MailAccessChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/api/mail/messages/{id}/flag', name: 'mail_message_flag', methods: ['POST'], priority: 1, requirements: ['id' => '\d+'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class MailMessageFlagController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailMessageRepository $messageRepository,
|
||||
private readonly MailProviderInterface $mailProvider,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly MailAccessChecker $accessChecker,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
||||
|
||||
$message = $this->messageRepository->find($id);
|
||||
if (null === $message) {
|
||||
throw new NotFoundHttpException('Message not found');
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$flagged = (bool) ($body['flagged'] ?? true);
|
||||
|
||||
try {
|
||||
$this->mailProvider->markFlagged($message->getFolder()->getPath(), $message->getUid(), $flagged);
|
||||
} catch (MailProviderException) {
|
||||
// Non bloquant
|
||||
}
|
||||
|
||||
$message->setIsFlagged($flagged);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->json(['id' => $message->getId(), 'isFlagged' => $message->isFlagged()]);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Mail\Exception\MailProviderException;
|
||||
use App\Mail\MailProviderInterface;
|
||||
use App\Repository\MailMessageRepository;
|
||||
use App\Security\MailAccessChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/api/mail/messages/{id}/read', name: 'mail_message_read', methods: ['POST'], priority: 1, requirements: ['id' => '\d+'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class MailMessageReadController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailMessageRepository $messageRepository,
|
||||
private readonly MailProviderInterface $mailProvider,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly MailAccessChecker $accessChecker,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
||||
|
||||
$message = $this->messageRepository->find($id);
|
||||
if (null === $message) {
|
||||
throw new NotFoundHttpException('Message not found');
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$read = (bool) ($body['read'] ?? true);
|
||||
|
||||
try {
|
||||
$this->mailProvider->markRead($message->getFolder()->getPath(), $message->getUid(), $read);
|
||||
} catch (MailProviderException) {
|
||||
// Non bloquant : on met quand meme a jour la BDD (sync IMAP au prochain cycle)
|
||||
}
|
||||
|
||||
$message->setIsRead($read);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->json(['id' => $message->getId(), 'isRead' => $message->isRead()]);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Repository\MailFolderRepository;
|
||||
use App\Repository\MailMessageRepository;
|
||||
use App\Security\MailAccessChecker;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/api/mail/folders/{folderPath}/messages', name: 'mail_messages_list', methods: ['GET'], priority: 1, requirements: ['folderPath' => '.+'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class MailMessagesListController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailFolderRepository $folderRepository,
|
||||
private readonly MailMessageRepository $messageRepository,
|
||||
private readonly MailAccessChecker $accessChecker,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, string $folderPath): JsonResponse
|
||||
{
|
||||
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
||||
|
||||
$decodedPath = urldecode($folderPath);
|
||||
|
||||
$folder = $this->folderRepository->findByPath($decodedPath);
|
||||
if (null === $folder) {
|
||||
throw new NotFoundHttpException(sprintf('Folder "%s" not found', $decodedPath));
|
||||
}
|
||||
|
||||
$limit = min((int) $request->query->get('limit', 50), 100);
|
||||
$cursor = $request->query->get('cursor');
|
||||
|
||||
$result = $this->messageRepository->findByFolderCursor($folder, $limit, $cursor ?: null);
|
||||
|
||||
$messages = array_map(static fn ($m) => [
|
||||
'id' => $m->getId(),
|
||||
'messageId' => $m->getMessageId(),
|
||||
'uid' => $m->getUid(),
|
||||
'subject' => $m->getSubject(),
|
||||
'fromAddress' => $m->getFromAddress(),
|
||||
'fromName' => $m->getFromName(),
|
||||
'toAddresses' => $m->getToAddresses(),
|
||||
'ccAddresses' => $m->getCcAddresses(),
|
||||
'sentAt' => $m->getSentAt()->format(DateTimeInterface::ATOM),
|
||||
'isRead' => $m->isRead(),
|
||||
'isFlagged' => $m->isFlagged(),
|
||||
'hasAttachments' => $m->hasAttachments(),
|
||||
'snippet' => $m->getSnippet(),
|
||||
], $result['messages']);
|
||||
|
||||
return $this->json([
|
||||
'messages' => $messages,
|
||||
'nextCursor' => $result['nextCursor'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Message\MailSyncRequested;
|
||||
use App\Security\MailAccessChecker;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/api/mail/sync', name: 'mail_sync_trigger', methods: ['POST'], priority: 1)]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class MailSyncTriggerController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MessageBusInterface $bus,
|
||||
private readonly MailAccessChecker $accessChecker,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
||||
|
||||
$body = json_decode($request->getContent(), true) ?? [];
|
||||
$folderPath = $body['folderPath'] ?? null;
|
||||
|
||||
$this->bus->dispatch(new MailSyncRequested($folderPath));
|
||||
|
||||
return $this->json(
|
||||
['message' => 'Synchronisation démarrée en arrière-plan'],
|
||||
Response::HTTP_ACCEPTED
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Mail\Exception\MailProviderException;
|
||||
use App\Mail\MailProviderInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
use Throwable;
|
||||
|
||||
#[Route('/api/mail/configuration/test', name: 'mail_configuration_test', methods: ['POST'], priority: 1)]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class MailTestConnectionController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailProviderInterface $mailProvider,
|
||||
) {}
|
||||
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
try {
|
||||
$foldersCount = $this->mailProvider->testConnection();
|
||||
|
||||
return $this->json([
|
||||
'ok' => true,
|
||||
'foldersCount' => $foldersCount,
|
||||
]);
|
||||
} catch (MailProviderException $e) {
|
||||
return $this->json([
|
||||
'ok' => false,
|
||||
'error' => 'Connexion IMAP impossible. Vérifiez la configuration.',
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return $this->json([
|
||||
'ok' => false,
|
||||
'error' => 'Erreur inattendue lors du test de connexion.',
|
||||
'detail' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Entity\Task;
|
||||
use App\Repository\MailMessageRepository;
|
||||
use App\Repository\TaskMailLinkRepository;
|
||||
use App\Security\MailAccessChecker;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/api/mail/messages/{id}/link-task/{taskId}', name: 'mail_unlink_task', methods: ['DELETE'], priority: 1, requirements: ['id' => '\d+', 'taskId' => '\d+'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class MailUnlinkTaskController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailMessageRepository $messageRepository,
|
||||
private readonly TaskMailLinkRepository $linkRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly MailAccessChecker $accessChecker,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id, int $taskId): JsonResponse
|
||||
{
|
||||
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
||||
|
||||
$message = $this->messageRepository->find($id);
|
||||
if (null === $message) {
|
||||
throw new NotFoundHttpException('Message not found');
|
||||
}
|
||||
|
||||
$task = $this->em->getRepository(Task::class)->find($taskId);
|
||||
if (null === $task) {
|
||||
throw new NotFoundHttpException('Task not found');
|
||||
}
|
||||
|
||||
$link = $this->linkRepository->findByTaskAndMessage($task, $message);
|
||||
if (null === $link) {
|
||||
throw new NotFoundHttpException('Link not found');
|
||||
}
|
||||
|
||||
$this->em->remove($link);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->json(null, Response::HTTP_NO_CONTENT);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Entity\Task;
|
||||
use App\Repository\TaskMailLinkRepository;
|
||||
use App\Security\MailAccessChecker;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
#[Route('/api/tasks/{id}/mails', name: 'task_mails_list', methods: ['GET'], priority: 1, requirements: ['id' => '\d+'])]
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class TaskMailsListController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly TaskMailLinkRepository $linkRepository,
|
||||
private readonly MailAccessChecker $accessChecker,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): JsonResponse
|
||||
{
|
||||
$this->accessChecker->ensureCanAccessMail($this->getUser());
|
||||
|
||||
$task = $this->em->getRepository(Task::class)->find($id);
|
||||
if (null === $task) {
|
||||
throw new NotFoundHttpException('Task not found');
|
||||
}
|
||||
|
||||
$links = $this->linkRepository->findByTask($task);
|
||||
|
||||
$data = array_map(static fn ($link) => [
|
||||
'id' => $link->getMailMessage()->getId(),
|
||||
'messageId' => $link->getMailMessage()->getMessageId(),
|
||||
'subject' => $link->getMailMessage()->getSubject(),
|
||||
'fromAddress' => $link->getMailMessage()->getFromAddress(),
|
||||
'fromName' => $link->getMailMessage()->getFromName(),
|
||||
'sentAt' => $link->getMailMessage()->getSentAt()->format(DateTimeInterface::ATOM),
|
||||
'isRead' => $link->getMailMessage()->isRead(),
|
||||
'isFlagged' => $link->getMailMessage()->isFlagged(),
|
||||
'snippet' => $link->getMailMessage()->getSnippet(),
|
||||
'linkedAt' => $link->getLinkedAt()->format(DateTimeInterface::ATOM),
|
||||
], $links);
|
||||
|
||||
return $this->json($data);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ namespace App\DataFixtures;
|
||||
|
||||
use App\Entity\Client;
|
||||
use App\Entity\ClientTicket;
|
||||
use App\Entity\MailConfiguration;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskEffort;
|
||||
@@ -707,21 +706,6 @@ class AppFixtures extends Fixture
|
||||
$taskRecurring->setRecurrence($recurrence);
|
||||
$manager->persist($taskRecurring);
|
||||
|
||||
// =============================================
|
||||
// Mail Configuration
|
||||
// =============================================
|
||||
$mailConfig = new MailConfiguration();
|
||||
$mailConfig->setImapHost('ssl0.ovh.net');
|
||||
$mailConfig->setImapPort(993);
|
||||
$mailConfig->setImapEncryption('ssl');
|
||||
$mailConfig->setSmtpHost('ssl0.ovh.net');
|
||||
$mailConfig->setSmtpPort(465);
|
||||
$mailConfig->setSmtpEncryption('ssl');
|
||||
$mailConfig->setUsername('lesstime@ovh.fr');
|
||||
$mailConfig->setSentFolderPath('Sent');
|
||||
$mailConfig->setEnabled(false);
|
||||
$manager->persist($mailConfig);
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,12 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
order: ['createdAt' => 'DESC'],
|
||||
)]
|
||||
#[ORM\Entity(repositoryClass: ClientTicketRepository::class)]
|
||||
#[ORM\Table(name: 'client_ticket')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number'])]
|
||||
#[ORM\Table(
|
||||
name: 'client_ticket',
|
||||
uniqueConstraints: [
|
||||
new ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number']),
|
||||
],
|
||||
)]
|
||||
class ClientTicket
|
||||
{
|
||||
public const string TYPE_BUG = 'bug';
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\MailConfigurationRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: MailConfigurationRepository::class)]
|
||||
#[ORM\Table(name: 'mail_configuration')]
|
||||
class MailConfiguration
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 10)]
|
||||
private string $protocol = 'imap';
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $imapHost = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $imapPort = 993;
|
||||
|
||||
#[ORM\Column(length: 10)]
|
||||
private string $imapEncryption = 'ssl';
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $smtpHost = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $smtpPort = 465;
|
||||
|
||||
#[ORM\Column(length: 10)]
|
||||
private string $smtpEncryption = 'ssl';
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $username = null;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $encryptedPassword = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $sentFolderPath = 'Sent';
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $enabled = false;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getProtocol(): string
|
||||
{
|
||||
return $this->protocol;
|
||||
}
|
||||
|
||||
public function setProtocol(string $protocol): static
|
||||
{
|
||||
$this->protocol = $protocol;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getImapHost(): ?string
|
||||
{
|
||||
return $this->imapHost;
|
||||
}
|
||||
|
||||
public function setImapHost(?string $imapHost): static
|
||||
{
|
||||
$this->imapHost = $imapHost;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getImapPort(): int
|
||||
{
|
||||
return $this->imapPort;
|
||||
}
|
||||
|
||||
public function setImapPort(int $imapPort): static
|
||||
{
|
||||
$this->imapPort = $imapPort;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getImapEncryption(): string
|
||||
{
|
||||
return $this->imapEncryption;
|
||||
}
|
||||
|
||||
public function setImapEncryption(string $imapEncryption): static
|
||||
{
|
||||
$this->imapEncryption = $imapEncryption;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSmtpHost(): ?string
|
||||
{
|
||||
return $this->smtpHost;
|
||||
}
|
||||
|
||||
public function setSmtpHost(?string $smtpHost): static
|
||||
{
|
||||
$this->smtpHost = $smtpHost;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSmtpPort(): int
|
||||
{
|
||||
return $this->smtpPort;
|
||||
}
|
||||
|
||||
public function setSmtpPort(int $smtpPort): static
|
||||
{
|
||||
$this->smtpPort = $smtpPort;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSmtpEncryption(): string
|
||||
{
|
||||
return $this->smtpEncryption;
|
||||
}
|
||||
|
||||
public function setSmtpEncryption(string $smtpEncryption): static
|
||||
{
|
||||
$this->smtpEncryption = $smtpEncryption;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUsername(): ?string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function setUsername(?string $username): static
|
||||
{
|
||||
$this->username = $username;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEncryptedPassword(): ?string
|
||||
{
|
||||
return $this->encryptedPassword;
|
||||
}
|
||||
|
||||
public function setEncryptedPassword(?string $encryptedPassword): static
|
||||
{
|
||||
$this->encryptedPassword = $encryptedPassword;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSentFolderPath(): string
|
||||
{
|
||||
return $this->sentFolderPath;
|
||||
}
|
||||
|
||||
public function setSentFolderPath(string $sentFolderPath): static
|
||||
{
|
||||
$this->sentFolderPath = $sentFolderPath;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function setEnabled(bool $enabled): static
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasPassword(): bool
|
||||
{
|
||||
return null !== $this->encryptedPassword;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\MailFolderRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: MailFolderRepository::class)]
|
||||
#[ORM\Table(name: 'mail_folder')]
|
||||
#[ORM\Index(columns: ['parent_path'], name: 'idx_mail_folder_parent_path')]
|
||||
class MailFolder
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 500, unique: true)]
|
||||
private string $path;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $displayName;
|
||||
|
||||
#[ORM\Column(length: 500, nullable: true)]
|
||||
private ?string $parentPath = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $unreadCount = 0;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $totalCount = 0;
|
||||
|
||||
#[ORM\Column(type: 'datetimetz_immutable', nullable: true)]
|
||||
private ?DateTimeImmutable $lastSyncedAt = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
public function setPath(string $path): static
|
||||
{
|
||||
$this->path = $path;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDisplayName(): string
|
||||
{
|
||||
return $this->displayName;
|
||||
}
|
||||
|
||||
public function setDisplayName(string $displayName): static
|
||||
{
|
||||
$this->displayName = $displayName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getParentPath(): ?string
|
||||
{
|
||||
return $this->parentPath;
|
||||
}
|
||||
|
||||
public function setParentPath(?string $parentPath): static
|
||||
{
|
||||
$this->parentPath = $parentPath;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUnreadCount(): int
|
||||
{
|
||||
return $this->unreadCount;
|
||||
}
|
||||
|
||||
public function setUnreadCount(int $unreadCount): static
|
||||
{
|
||||
$this->unreadCount = $unreadCount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTotalCount(): int
|
||||
{
|
||||
return $this->totalCount;
|
||||
}
|
||||
|
||||
public function setTotalCount(int $totalCount): static
|
||||
{
|
||||
$this->totalCount = $totalCount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastSyncedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->lastSyncedAt;
|
||||
}
|
||||
|
||||
public function setLastSyncedAt(?DateTimeImmutable $lastSyncedAt): static
|
||||
{
|
||||
$this->lastSyncedAt = $lastSyncedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\MailMessageRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: MailMessageRepository::class)]
|
||||
#[ORM\Table(name: 'mail_message')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_mail_message_folder_uid', columns: ['folder_id', 'uid'])]
|
||||
#[ORM\Index(columns: ['sent_at'], name: 'idx_mail_message_sent_at')]
|
||||
#[ORM\Index(columns: ['is_read'], name: 'idx_mail_message_is_read')]
|
||||
#[ORM\Index(columns: ['message_id'], name: 'idx_mail_message_message_id')]
|
||||
class MailMessage
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 500)]
|
||||
private string $messageId;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: MailFolder::class)]
|
||||
#[ORM\JoinColumn(name: 'folder_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private MailFolder $folder;
|
||||
|
||||
#[ORM\Column]
|
||||
private int $uid;
|
||||
|
||||
#[ORM\Column(length: 500, nullable: true)]
|
||||
private ?string $subject = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
private string $fromAddress;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $fromName = null;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $toAddresses = [];
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $ccAddresses = null;
|
||||
|
||||
#[ORM\Column(type: 'datetimetz_immutable')]
|
||||
private DateTimeImmutable $sentAt;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $isRead = false;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $isFlagged = false;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $hasAttachments = false;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $snippet = null;
|
||||
|
||||
#[ORM\Column(type: 'datetimetz_immutable')]
|
||||
private DateTimeImmutable $syncedAt;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getMessageId(): string
|
||||
{
|
||||
return $this->messageId;
|
||||
}
|
||||
|
||||
public function setMessageId(string $messageId): static
|
||||
{
|
||||
$this->messageId = $messageId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFolder(): MailFolder
|
||||
{
|
||||
return $this->folder;
|
||||
}
|
||||
|
||||
public function setFolder(MailFolder $folder): static
|
||||
{
|
||||
$this->folder = $folder;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUid(): int
|
||||
{
|
||||
return $this->uid;
|
||||
}
|
||||
|
||||
public function setUid(int $uid): static
|
||||
{
|
||||
$this->uid = $uid;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSubject(): ?string
|
||||
{
|
||||
return $this->subject;
|
||||
}
|
||||
|
||||
public function setSubject(?string $subject): static
|
||||
{
|
||||
$this->subject = $subject;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFromAddress(): string
|
||||
{
|
||||
return $this->fromAddress;
|
||||
}
|
||||
|
||||
public function setFromAddress(string $fromAddress): static
|
||||
{
|
||||
$this->fromAddress = $fromAddress;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFromName(): ?string
|
||||
{
|
||||
return $this->fromName;
|
||||
}
|
||||
|
||||
public function setFromName(?string $fromName): static
|
||||
{
|
||||
$this->fromName = $fromName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getToAddresses(): array
|
||||
{
|
||||
return $this->toAddresses;
|
||||
}
|
||||
|
||||
public function setToAddresses(array $toAddresses): static
|
||||
{
|
||||
$this->toAddresses = $toAddresses;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCcAddresses(): ?array
|
||||
{
|
||||
return $this->ccAddresses;
|
||||
}
|
||||
|
||||
public function setCcAddresses(?array $ccAddresses): static
|
||||
{
|
||||
$this->ccAddresses = $ccAddresses;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSentAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->sentAt;
|
||||
}
|
||||
|
||||
public function setSentAt(DateTimeImmutable $sentAt): static
|
||||
{
|
||||
$this->sentAt = $sentAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isRead(): bool
|
||||
{
|
||||
return $this->isRead;
|
||||
}
|
||||
|
||||
public function setIsRead(bool $isRead): static
|
||||
{
|
||||
$this->isRead = $isRead;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isFlagged(): bool
|
||||
{
|
||||
return $this->isFlagged;
|
||||
}
|
||||
|
||||
public function setIsFlagged(bool $isFlagged): static
|
||||
{
|
||||
$this->isFlagged = $isFlagged;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function hasAttachments(): bool
|
||||
{
|
||||
return $this->hasAttachments;
|
||||
}
|
||||
|
||||
public function setHasAttachments(bool $hasAttachments): static
|
||||
{
|
||||
$this->hasAttachments = $hasAttachments;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSnippet(): ?string
|
||||
{
|
||||
return $this->snippet;
|
||||
}
|
||||
|
||||
public function setSnippet(?string $snippet): static
|
||||
{
|
||||
$this->snippet = $snippet;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSyncedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->syncedAt;
|
||||
}
|
||||
|
||||
public function setSyncedAt(DateTimeImmutable $syncedAt): static
|
||||
{
|
||||
$this->syncedAt = $syncedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\TaskMailLinkRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: TaskMailLinkRepository::class)]
|
||||
#[ORM\Table(name: 'task_mail_link')]
|
||||
#[ORM\UniqueConstraint(name: 'uq_task_mail_link', columns: ['task_id', 'mail_message_id'])]
|
||||
class TaskMailLink
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Task::class)]
|
||||
#[ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Task $task;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: MailMessage::class)]
|
||||
#[ORM\JoinColumn(name: 'mail_message_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private MailMessage $mailMessage;
|
||||
|
||||
#[ORM\Column(type: 'datetimetz_immutable')]
|
||||
private DateTimeImmutable $linkedAt;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||
#[ORM\JoinColumn(name: 'linked_by_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?User $linkedBy = 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 getMailMessage(): MailMessage
|
||||
{
|
||||
return $this->mailMessage;
|
||||
}
|
||||
|
||||
public function setMailMessage(MailMessage $mailMessage): static
|
||||
{
|
||||
$this->mailMessage = $mailMessage;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLinkedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->linkedAt;
|
||||
}
|
||||
|
||||
public function setLinkedAt(DateTimeImmutable $linkedAt): static
|
||||
{
|
||||
$this->linkedAt = $linkedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLinkedBy(): ?User
|
||||
{
|
||||
return $this->linkedBy;
|
||||
}
|
||||
|
||||
public function setLinkedBy(?User $linkedBy): static
|
||||
{
|
||||
$this->linkedBy = $linkedBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Dto;
|
||||
|
||||
final readonly class MailAttachmentDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $partNumber,
|
||||
public string $filename,
|
||||
public string $mimeType,
|
||||
public int $size,
|
||||
) {}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Dto;
|
||||
|
||||
final readonly class MailFolderDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $path,
|
||||
public string $displayName,
|
||||
public ?string $parentPath,
|
||||
public int $unreadCount,
|
||||
public int $totalCount,
|
||||
) {}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Dto;
|
||||
|
||||
final readonly class MailMessageDetailDto
|
||||
{
|
||||
/**
|
||||
* @param list<MailAttachmentDto> $attachments
|
||||
*/
|
||||
public function __construct(
|
||||
public MailMessageHeaderDto $header,
|
||||
public ?string $bodyHtml,
|
||||
public ?string $bodyText,
|
||||
public array $attachments,
|
||||
) {}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Dto;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class MailMessageHeaderDto
|
||||
{
|
||||
public function __construct(
|
||||
public int $uid,
|
||||
public string $messageId,
|
||||
public ?string $subject,
|
||||
public string $fromAddress,
|
||||
public ?string $fromName,
|
||||
public array $toAddresses,
|
||||
public ?array $ccAddresses,
|
||||
public DateTimeImmutable $sentAt,
|
||||
public bool $isRead,
|
||||
public bool $isFlagged,
|
||||
public bool $hasAttachments,
|
||||
public ?string $snippet,
|
||||
) {}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Dto;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
final readonly class MailSyncReport
|
||||
{
|
||||
/**
|
||||
* @param list<string> $errors
|
||||
*/
|
||||
public function __construct(
|
||||
public int $createdCount,
|
||||
public int $updatedCount,
|
||||
public int $deletedCount,
|
||||
public int $foldersScanned,
|
||||
public array $errors,
|
||||
public float $durationSeconds,
|
||||
public DateTimeImmutable $startedAt,
|
||||
public DateTimeImmutable $finishedAt,
|
||||
) {}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class MailProviderException extends RuntimeException
|
||||
{
|
||||
public static function connectionFailed(string $reason): self
|
||||
{
|
||||
return new self(sprintf('Mail provider connection failed: %s', $reason));
|
||||
}
|
||||
|
||||
public static function operationFailed(string $operation, string $reason): self
|
||||
{
|
||||
return new self(sprintf('Mail provider operation "%s" failed: %s', $operation, $reason));
|
||||
}
|
||||
}
|
||||
@@ -1,403 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Dto\MailAttachmentDto;
|
||||
use App\Mail\Dto\MailFolderDto;
|
||||
use App\Mail\Dto\MailMessageDetailDto;
|
||||
use App\Mail\Dto\MailMessageHeaderDto;
|
||||
use App\Mail\Exception\MailProviderException;
|
||||
use App\Repository\MailConfigurationRepository;
|
||||
use App\Service\TokenEncryptor;
|
||||
use DateTimeImmutable;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use SodiumException;
|
||||
use Throwable;
|
||||
use Webklex\PHPIMAP\Client;
|
||||
use Webklex\PHPIMAP\ClientManager;
|
||||
use Webklex\PHPIMAP\IMAP;
|
||||
|
||||
final class ImapMailProvider implements MailProviderInterface
|
||||
{
|
||||
private ?Client $client = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly MailConfigurationRepository $configRepository,
|
||||
private readonly TokenEncryptor $tokenEncryptor,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Closes the reused IMAP connection. Call once at the end of a batch
|
||||
* synchronisation to release the socket; HTTP requests can ignore it
|
||||
* (the connection dies with the process).
|
||||
*/
|
||||
public function closeConnection(): void
|
||||
{
|
||||
if (null !== $this->client && $this->client->isConnected()) {
|
||||
try {
|
||||
$this->client->disconnect();
|
||||
} catch (Throwable) {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
$this->client = null;
|
||||
}
|
||||
|
||||
public function testConnection(): int
|
||||
{
|
||||
$client = $this->getClient(requireEnabled: false);
|
||||
|
||||
try {
|
||||
$folders = $client->getFolders(false);
|
||||
|
||||
return count($folders);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('ImapMailProvider::testConnection failed: '.$e->getMessage());
|
||||
|
||||
throw MailProviderException::connectionFailed($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function listFolders(): array
|
||||
{
|
||||
$client = $this->getClient();
|
||||
|
||||
try {
|
||||
$folders = $client->getFolders(false);
|
||||
$result = [];
|
||||
|
||||
foreach ($folders as $folder) {
|
||||
$path = $folder->path;
|
||||
$parentPath = null;
|
||||
$delimiter = $folder->delimiter ?? '.';
|
||||
$lastDelim = strrpos($path, $delimiter);
|
||||
if (false !== $lastDelim && $lastDelim > 0) {
|
||||
$parentPath = substr($path, 0, $lastDelim);
|
||||
}
|
||||
|
||||
$result[] = new MailFolderDto(
|
||||
path: $path,
|
||||
displayName: $folder->name,
|
||||
parentPath: $parentPath,
|
||||
unreadCount: (int) ($folder->status['unseen'] ?? 0),
|
||||
totalCount: (int) ($folder->status['messages'] ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (MailProviderException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('ImapMailProvider::listFolders failed: '.$e->getMessage());
|
||||
|
||||
throw MailProviderException::operationFailed('listFolders', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function listMessages(string $folderPath, int $limit, int $offset): array
|
||||
{
|
||||
$client = $this->getClient();
|
||||
|
||||
try {
|
||||
$folder = $client->getFolder($folderPath);
|
||||
if (null === $folder) {
|
||||
throw MailProviderException::operationFailed('listMessages', sprintf('Folder %s not found', $folderPath));
|
||||
}
|
||||
|
||||
$messages = $folder->query()
|
||||
->whereAll()
|
||||
->setFetchBody(false)
|
||||
->leaveUnread()
|
||||
->setSequence(IMAP::ST_UID)
|
||||
->get()
|
||||
;
|
||||
|
||||
$result = [];
|
||||
$items = array_slice($messages->toArray(), $offset, $limit);
|
||||
|
||||
foreach ($items as $message) {
|
||||
$result[] = $this->buildHeaderDto($message, withSnippet: false);
|
||||
}
|
||||
|
||||
return $result;
|
||||
} catch (MailProviderException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error(sprintf('ImapMailProvider::listMessages failed for folder %s: %s', $folderPath, $e->getMessage()));
|
||||
|
||||
throw MailProviderException::operationFailed('listMessages', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function fetchMessage(string $folderPath, int $uid): MailMessageDetailDto
|
||||
{
|
||||
$client = $this->getClient();
|
||||
|
||||
try {
|
||||
$folder = $client->getFolder($folderPath);
|
||||
if (null === $folder) {
|
||||
throw MailProviderException::operationFailed('fetchMessage', sprintf('Folder %s not found', $folderPath));
|
||||
}
|
||||
|
||||
$message = $folder->query()->uid($uid)->leaveUnread()->setSequence(IMAP::ST_UID)->get()->first();
|
||||
|
||||
if (null === $message) {
|
||||
throw MailProviderException::operationFailed('fetchMessage', sprintf('UID %d not found in folder %s', $uid, $folderPath));
|
||||
}
|
||||
|
||||
$header = $this->buildHeaderDto($message);
|
||||
$bodyHtml = $message->getHTMLBody(false) ?: null;
|
||||
$bodyText = $message->getTextBody() ?: null;
|
||||
$attachments = [];
|
||||
|
||||
foreach ($message->getAttachments() as $att) {
|
||||
$attachments[] = new MailAttachmentDto(
|
||||
partNumber: (string) ($att->part_number ?? '1'),
|
||||
filename: $att->getName() ?? 'attachment',
|
||||
mimeType: $att->getMimeType() ?? 'application/octet-stream',
|
||||
size: $att->getSize() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
return new MailMessageDetailDto(
|
||||
header: $header,
|
||||
bodyHtml: $bodyHtml,
|
||||
bodyText: $bodyText,
|
||||
attachments: $attachments,
|
||||
);
|
||||
} catch (MailProviderException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error(sprintf('ImapMailProvider::fetchMessage failed uid=%d folder=%s: %s', $uid, $folderPath, $e->getMessage()));
|
||||
|
||||
throw MailProviderException::operationFailed('fetchMessage', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function markRead(string $folderPath, int $uid, bool $read): void
|
||||
{
|
||||
$client = $this->getClient();
|
||||
|
||||
try {
|
||||
$folder = $client->getFolder($folderPath);
|
||||
if (null === $folder) {
|
||||
throw MailProviderException::operationFailed('markRead', sprintf('Folder %s not found', $folderPath));
|
||||
}
|
||||
|
||||
$message = $folder->query()->uid($uid)->leaveUnread()->setSequence(IMAP::ST_UID)->get()->first();
|
||||
|
||||
if (null === $message) {
|
||||
throw MailProviderException::operationFailed('markRead', sprintf('UID %d not found', $uid));
|
||||
}
|
||||
|
||||
if ($read) {
|
||||
$message->setFlag('Seen');
|
||||
} else {
|
||||
$message->unsetFlag('Seen');
|
||||
}
|
||||
} catch (MailProviderException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error(sprintf('ImapMailProvider::markRead failed uid=%d: %s', $uid, $e->getMessage()));
|
||||
|
||||
throw MailProviderException::operationFailed('markRead', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function markFlagged(string $folderPath, int $uid, bool $flagged): void
|
||||
{
|
||||
$client = $this->getClient();
|
||||
|
||||
try {
|
||||
$folder = $client->getFolder($folderPath);
|
||||
if (null === $folder) {
|
||||
throw MailProviderException::operationFailed('markFlagged', sprintf('Folder %s not found', $folderPath));
|
||||
}
|
||||
|
||||
$message = $folder->query()->uid($uid)->leaveUnread()->setSequence(IMAP::ST_UID)->get()->first();
|
||||
|
||||
if (null === $message) {
|
||||
throw MailProviderException::operationFailed('markFlagged', sprintf('UID %d not found', $uid));
|
||||
}
|
||||
|
||||
if ($flagged) {
|
||||
$message->setFlag('Flagged');
|
||||
} else {
|
||||
$message->unsetFlag('Flagged');
|
||||
}
|
||||
} catch (MailProviderException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error(sprintf('ImapMailProvider::markFlagged failed uid=%d: %s', $uid, $e->getMessage()));
|
||||
|
||||
throw MailProviderException::operationFailed('markFlagged', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function moveMessage(string $folderPath, int $uid, string $targetFolder): void
|
||||
{
|
||||
$client = $this->getClient();
|
||||
|
||||
try {
|
||||
$folder = $client->getFolder($folderPath);
|
||||
if (null === $folder) {
|
||||
throw MailProviderException::operationFailed('moveMessage', sprintf('Folder %s not found', $folderPath));
|
||||
}
|
||||
|
||||
$message = $folder->query()->uid($uid)->leaveUnread()->setSequence(IMAP::ST_UID)->get()->first();
|
||||
|
||||
if (null === $message) {
|
||||
throw MailProviderException::operationFailed('moveMessage', sprintf('UID %d not found', $uid));
|
||||
}
|
||||
|
||||
$message->moveToFolder($targetFolder);
|
||||
} catch (MailProviderException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error(sprintf('ImapMailProvider::moveMessage failed uid=%d: %s', $uid, $e->getMessage()));
|
||||
|
||||
throw MailProviderException::operationFailed('moveMessage', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function fetchAttachment(string $folderPath, int $uid, string $partNumber): string
|
||||
{
|
||||
$client = $this->getClient();
|
||||
|
||||
try {
|
||||
$folder = $client->getFolder($folderPath);
|
||||
if (null === $folder) {
|
||||
throw MailProviderException::operationFailed('fetchAttachment', sprintf('Folder %s not found', $folderPath));
|
||||
}
|
||||
|
||||
$message = $folder->query()->uid($uid)->leaveUnread()->setSequence(IMAP::ST_UID)->get()->first();
|
||||
|
||||
if (null === $message) {
|
||||
throw MailProviderException::operationFailed('fetchAttachment', sprintf('UID %d not found', $uid));
|
||||
}
|
||||
|
||||
foreach ($message->getAttachments() as $att) {
|
||||
if ((string) ($att->part_number ?? '1') === $partNumber) {
|
||||
return (string) $att->getContent();
|
||||
}
|
||||
}
|
||||
|
||||
throw MailProviderException::operationFailed('fetchAttachment', sprintf('Part %s not found in UID %d', $partNumber, $uid));
|
||||
} catch (MailProviderException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error(sprintf('ImapMailProvider::fetchAttachment failed uid=%d part=%s: %s', $uid, $partNumber, $e->getMessage()));
|
||||
|
||||
throw MailProviderException::operationFailed('fetchAttachment', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function getClient(bool $requireEnabled = true): Client
|
||||
{
|
||||
if (null !== $this->client && $this->client->isConnected()) {
|
||||
return $this->client;
|
||||
}
|
||||
|
||||
$config = $this->configRepository->findSingleton();
|
||||
|
||||
if (null === $config) {
|
||||
throw MailProviderException::connectionFailed('Mail configuration is missing');
|
||||
}
|
||||
|
||||
if ($requireEnabled && !$config->isEnabled()) {
|
||||
throw MailProviderException::connectionFailed('Mail configuration is disabled');
|
||||
}
|
||||
|
||||
if (null === $config->getEncryptedPassword()) {
|
||||
throw MailProviderException::connectionFailed('No password configured');
|
||||
}
|
||||
|
||||
$password = $this->tokenEncryptor->decrypt($config->getEncryptedPassword());
|
||||
|
||||
try {
|
||||
$manager = new ClientManager();
|
||||
$client = $manager->make([
|
||||
'host' => $config->getImapHost(),
|
||||
'port' => $config->getImapPort(),
|
||||
'encryption' => $config->getImapEncryption(),
|
||||
'validate_cert' => true,
|
||||
'username' => $config->getUsername(),
|
||||
'password' => $password,
|
||||
'protocol' => 'imap',
|
||||
]);
|
||||
|
||||
$client->connect();
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('IMAP connection failed: '.$e->getMessage());
|
||||
|
||||
throw MailProviderException::connectionFailed($e->getMessage());
|
||||
} finally {
|
||||
try {
|
||||
sodium_memzero($password);
|
||||
} catch (SodiumException) {
|
||||
// ignore: interned strings can't be zeroed
|
||||
}
|
||||
}
|
||||
|
||||
$this->client = $client;
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function buildHeaderDto(mixed $message, bool $withSnippet = true): MailMessageHeaderDto
|
||||
{
|
||||
$from = $message->getFrom()->first();
|
||||
$fromAddress = null !== $from ? (string) $from->mail : '';
|
||||
$fromName = null !== $from && null !== $from->personal ? (string) $from->personal : null;
|
||||
|
||||
$toAddresses = [];
|
||||
foreach ($message->getTo() as $addr) {
|
||||
$toAddresses[] = (string) $addr->mail;
|
||||
}
|
||||
|
||||
$ccAddresses = null;
|
||||
$cc = $message->getCc();
|
||||
if (null !== $cc && $cc->count() > 0) {
|
||||
$ccAddresses = [];
|
||||
foreach ($cc as $addr) {
|
||||
$ccAddresses[] = (string) $addr->mail;
|
||||
}
|
||||
}
|
||||
|
||||
$sentAt = new DateTimeImmutable();
|
||||
$dateAttr = $message->getDate();
|
||||
if (null !== $dateAttr) {
|
||||
try {
|
||||
$sentAt = DateTimeImmutable::createFromInterface($dateAttr->toDate());
|
||||
} catch (Throwable) {
|
||||
// keep default when the header date is missing or unparsable
|
||||
}
|
||||
}
|
||||
|
||||
$snippet = null;
|
||||
if ($withSnippet) {
|
||||
$text = $message->getTextBody();
|
||||
if (null !== $text && '' !== $text) {
|
||||
$snippet = mb_substr(strip_tags($text), 0, 200);
|
||||
}
|
||||
}
|
||||
|
||||
return new MailMessageHeaderDto(
|
||||
uid: (int) $message->getUid(),
|
||||
messageId: (string) $message->getMessageId(),
|
||||
subject: '' !== (string) $message->getSubject() ? (string) $message->getSubject() : null,
|
||||
fromAddress: $fromAddress,
|
||||
fromName: $fromName,
|
||||
toAddresses: $toAddresses,
|
||||
ccAddresses: $ccAddresses,
|
||||
sentAt: $sentAt,
|
||||
isRead: $message->hasFlag('Seen'),
|
||||
isFlagged: $message->hasFlag('Flagged'),
|
||||
hasAttachments: $message->hasAttachments(),
|
||||
snippet: $snippet,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Dto\MailFolderDto;
|
||||
use App\Mail\Dto\MailMessageDetailDto;
|
||||
use App\Mail\Dto\MailMessageHeaderDto;
|
||||
use App\Mail\Exception\MailProviderException;
|
||||
|
||||
interface MailProviderInterface
|
||||
{
|
||||
/**
|
||||
* Opens a connection using the stored configuration and returns the number
|
||||
* of folders found. Used by the admin "test connection" endpoint, so it
|
||||
* MUST work even when the configuration is not yet enabled.
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function testConnection(): int;
|
||||
|
||||
/**
|
||||
* Releases any reused network connection held by the provider.
|
||||
* Safe to call multiple times; a no-op if nothing is open.
|
||||
*/
|
||||
public function closeConnection(): void;
|
||||
|
||||
/**
|
||||
* Returns the full folder tree of the configured mailbox.
|
||||
*
|
||||
* @return list<MailFolderDto>
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function listFolders(): array;
|
||||
|
||||
/**
|
||||
* Returns a paginated list of message headers for the given folder.
|
||||
*
|
||||
* @return list<MailMessageHeaderDto>
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function listMessages(string $folderPath, int $limit, int $offset): array;
|
||||
|
||||
/**
|
||||
* Fetches the full message (headers + body + attachments list) by UID.
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function fetchMessage(string $folderPath, int $uid): MailMessageDetailDto;
|
||||
|
||||
/**
|
||||
* Marks a message as read or unread on the IMAP server.
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function markRead(string $folderPath, int $uid, bool $read): void;
|
||||
|
||||
/**
|
||||
* Marks a message as flagged (starred) or unflagged on the IMAP server.
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function markFlagged(string $folderPath, int $uid, bool $flagged): void;
|
||||
|
||||
/**
|
||||
* Moves a message from one folder to another on the IMAP server.
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function moveMessage(string $folderPath, int $uid, string $targetFolder): void;
|
||||
|
||||
/**
|
||||
* Fetches the raw binary content of an attachment by its MIME part number.
|
||||
*
|
||||
* @throws MailProviderException
|
||||
*/
|
||||
public function fetchAttachment(string $folderPath, int $uid, string $partNumber): string;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Message;
|
||||
|
||||
final readonly class MailSyncRequested
|
||||
{
|
||||
public function __construct(
|
||||
public ?string $folderPath = null,
|
||||
) {}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\MessageHandler;
|
||||
|
||||
use App\Message\MailSyncRequested;
|
||||
use App\Repository\MailFolderRepository;
|
||||
use App\Service\MailSyncService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Throwable;
|
||||
|
||||
#[AsMessageHandler]
|
||||
final readonly class MailSyncRequestedHandler
|
||||
{
|
||||
public function __construct(
|
||||
private MailSyncService $mailSyncService,
|
||||
private MailFolderRepository $folderRepository,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function __invoke(MailSyncRequested $message): void
|
||||
{
|
||||
try {
|
||||
if (null !== $message->folderPath) {
|
||||
$folder = $this->folderRepository->findByPath($message->folderPath);
|
||||
if (null !== $folder) {
|
||||
$report = $this->mailSyncService->syncFolder($folder);
|
||||
$this->logger->info(sprintf(
|
||||
'MailSyncRequested handled for folder "%s": %d created, %d updated, %d deleted',
|
||||
$message->folderPath,
|
||||
$report->createdCount,
|
||||
$report->updatedCount,
|
||||
$report->deletedCount,
|
||||
));
|
||||
} else {
|
||||
$this->logger->warning(sprintf('MailSyncRequested: folder "%s" not found in DB', $message->folderPath));
|
||||
}
|
||||
} else {
|
||||
$report = $this->mailSyncService->syncAll();
|
||||
$this->logger->info(sprintf(
|
||||
'MailSyncRequested handled (all folders): %d created, %d updated, %d deleted, %d folders scanned',
|
||||
$report->createdCount,
|
||||
$report->updatedCount,
|
||||
$report->deletedCount,
|
||||
$report->foldersScanned,
|
||||
));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('MailSyncRequestedHandler failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\MailConfiguration;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class MailConfigurationRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, MailConfiguration::class);
|
||||
}
|
||||
|
||||
public function findSingleton(): ?MailConfiguration
|
||||
{
|
||||
return $this->createQueryBuilder('m')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\MailFolder;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class MailFolderRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, MailFolder::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<MailFolder>
|
||||
*/
|
||||
public function findAllOrderedByPath(): array
|
||||
{
|
||||
return $this->createQueryBuilder('f')
|
||||
->orderBy('f.path', 'ASC')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
|
||||
public function findByPath(string $path): ?MailFolder
|
||||
{
|
||||
return $this->findOneBy(['path' => $path]);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user