Compare commits

...

111 Commits

Author SHA1 Message Date
gitea-actions
eb2adc9fdc chore: bump version to v0.4.3
All checks were successful
Build & Push Docker Image / build (push) Successful in 53s
Auto Tag Develop / tag (push) Successful in 7s
2026-05-20 08:22:48 +00:00
Matthieu
4775cbf184 feat(ui) : palette de couleurs élargie + couleur personnalisée, fix champ code projet
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
ColorPicker : passe de 9 à 18 teintes prédéfinies (les 9 historiques
conservées en tête pour ne pas désassocier les couleurs existantes) et
ajoute une pastille « couleur personnalisée » (input natif type=color)
permettant n'importe quel hex. Partagé, donc bénéficie aussi aux tags,
priorités, groupes et workflows.

fix(project) : le champ code restait en minuscules. Le @input mutait
form.code à partir de l'ancienne valeur, puis l'émission update:modelValue
de MalioInputText l'écrasait avec la saisie brute → form.code en
minuscules (affiché en majuscules via CSS) → /^[A-Z]{2,10}$/ en échec →
création bloquée. Remplacé par un computed setter (source unique de
vérité : majuscules + lettres uniquement + max 10) et maxLength sur le
champ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:22:38 +02:00
gitea-actions
8be96bce0c chore: bump version to v0.4.2
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 27s
2026-05-20 07:56:32 +00:00
Matthieu
fb97b8d4e3 fix(mail) : sync à la demande synchrone pour le bouton rafraîchir
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Route MailSyncRequested vers le transport sync au lieu d'async : la
synchro IMAP s'exécute pendant la requête HTTP du clic « rafraîchir »,
donc le re-fetch du front voit immédiatement les nouveaux mails, sans
worker messenger:consume à maintenir en prod. La sync de fond reste
assurée par le cron OS (app:mail:sync, synchrone, indépendant du bus).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:56:23 +02:00
gitea-actions
913d3b7d93 chore: bump version to v0.4.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 1m16s
2026-05-20 07:48:17 +00:00
90bf46f598 Merge pull request 'feat(mail) : intégration mail OVH IMAP — boîte partagée, lecture, création/lien tâche' (#5) from feat/mail-integration into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Reviewed-on: #5
2026-05-20 07:45:31 +00:00
e5c5371c74 Merge branch 'develop' into feat/mail-integration 2026-05-20 07:45:09 +00:00
40a1d737f3 Merge pull request 'feat(help) : centre d'aide in-app /help avec 9 sections' (#4) from feat/in-app-help-doc into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Reviewed-on: #4
2026-05-20 07:44:43 +00:00
595b8f5be3 Merge pull request 'feat(workflow) : workflows de statuts par projet (kanban custom)' (#3) from feat/project-workflows into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 3m47s
Reviewed-on: #3
2026-05-20 07:44:09 +00:00
a40d11503a feat(mail) : allège la barre d'actions du lecteur de mail
Boutons "Créer une tâche" / "Lier à une tâche" réduits (text-xs, padding compact),
suppression des actions "Marquer comme non lu" et "Marquer important".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:24:00 +02:00
760649170e docs : pointeur CLAUDE.md vers la doc d'intégration mail (reprise)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:22:36 +02:00
b0f05da84a docs(mail) : section "Statut & reprise" — handoff complet (bugs corrigés, points en suspens, commandes) pour reprise sur autre poste
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:22:11 +02:00
c75dfa0371 fix(mail) : synchro multi-dossiers fiable contre OVH
Trois causes racines révélées par une vraie synchro complète (139 dossiers) :
- contrainte UNIQUE globale sur message_id : fausse pour IMAP (un même Message-ID
  existe dans plusieurs dossiers) → violation → fermeture de l'EntityManager →
  cascade qui tuait tous les dossiers suivants. Migration : index simple à la place.
- 139 connexions IMAP (une par dossier) → throttling OVH (failed to authenticate) :
  réutilisation d'une seule connexion (closeConnection() ajouté à l'interface).
- état de connexion corrompu après un dossier en erreur (must be in SELECTED state) :
  reconnexion ciblée après chaque dossier en échec.
- garde anti-cascade : reset du ManagerRegistry + arrêt propre si l'EM se ferme.

Résultat : 456 messages sur 57 dossiers (avant : 188/30 puis crash). Les rares
dossiers à encodage spécial sont skippés proprement et réessayés au cycle suivant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:21:02 +02:00
c6fa5a534e feat(mail) : arbre des dossiers repliable — chevrons, sous-dossiers masqués par défaut
Seuls les dossiers racine sont affichés au départ ; chevron pour déplier/replier
chaque dossier ayant des sous-dossiers. Le clic sur le nom sélectionne toujours le dossier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:05:00 +02:00
17b5fa2340 fix(mail) : corrige le fetch IMAP réel contre OVH (listMessages cassé)
Quatre bugs révélés en testant contre une vraie boîte OVH (les tests mockaient
le provider, donc jamais exercés) :
- requête sans critère → "BAD parse error: zero-length content" : ajout de whereAll()
- getDate()/getSubject() renvoient des Attribute webklex v6, pas des scalaires : casts explicites
- séquence par défaut ST_MSGN → le peek() de webklex faisait un STORE par numéro de
  séquence rejeté par OVH ("flag could not be removed") : force ST_UID sur toutes les requêtes
- snippet via getTextBody() forçait un fetch de corps par mail (sync 179s + peek) :
  setFetchBody(false) au listing, snippet désormais optionnel

Sync INBOX : 9 messages en 1,6s (avant : échec en 179s).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:57:17 +02:00
79d3414824 fix(mail) : aligne le contrat front/back pour le listing et le détail des messages
Le service appelait GET /mail/messages?folder=X (404) au lieu de la vraie route
/mail/folders/{path}/messages. Ajoute aussi une couche de mapping backend→DTO
(messages→items, fromAddress→fromEmail, toAddresses→toRecipients, détail plat→imbriqué)
pour réconcilier la dérive de contrat entre Phase 3 (API) et Phase 4 (DTOs front).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:46:21 +02:00
f313e74c9e fix(mail) : le test de connexion fonctionne même si la config est désactivée + remonte l'erreur IMAP réelle
Le guard enabled dans getClient() bloquait le test de connexion alors que le
workflow naturel est configurer → tester → activer. getClient(requireEnabled)
permet au nouveau testConnection() de se connecter sans exiger enabled=true.
Le controller (ROLE_ADMIN) renvoie désormais le détail de l'erreur pour faciliter
le diagnostic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:46:11 +02:00
7a682b4662 docs(mail) : checklist prod + sécurité, guide intégration complet, mention README 2026-05-20 00:59:31 +02:00
d6f430ca35 feat(mail) : clés i18n mail.sidebar.* + mail.admin.* (Phase 7) 2026-05-20 00:58:05 +02:00
4d7ff9be26 feat(mail) : sidebar — lien Messagerie + badge unread + polling lifecycle (start au login, stop au logout) 2026-05-20 00:57:41 +02:00
7c0d3372a9 feat(mail) : intègre onglet Mail dans pages/admin.vue 2026-05-20 00:57:19 +02:00
d36429f058 feat(mail) : AdminMailTab — form IMAP/SMTP/credentials + test connexion + indicateur hasPassword 2026-05-20 00:57:10 +02:00
28b673eec8 docs(mail) : plan détaillé Phase 7 — AdminMailTab, sidebar+badge, polling, doc finale (9 tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:55:27 +02:00
bad292a316 feat(mail) : pages/mail.vue — branche handlers Phase 6 (MailCreateTaskModal + MailLinkTaskModal) 2026-05-20 00:50:31 +02:00
273234626f feat(mail) : onglet Mails dans TaskModal — liste mails liés, bouton lier, MailPickerModal 2026-05-20 00:49:57 +02:00
96c7d902e7 feat(mail) : MailPickerModal — sélection mail depuis dossier courant, liaison taskId 2026-05-20 00:48:37 +02:00
f62c790449 feat(mail) : MailLinkTaskModal — autocomplete tâches, filtre projet, debounce 300ms 2026-05-20 00:47:55 +02:00
13cec9a46a feat(mail) : MailCreateTaskModal — picker projet/groupe/priorité, appel createTaskFromMail 2026-05-20 00:47:08 +02:00
d676fdcb0c feat(mail) : clés i18n Phase 6 — createTaskModal, linkTaskModal, pickerModal, taskTab 2026-05-20 00:46:09 +02:00
bfcf712123 docs(mail) : plan détaillé Phase 6 — modals create/link task + onglet Mails dans TaskModal (8 tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:44:26 +02:00
622fcf72c1 feat(mail) : page /mail — layout 3 colonnes, deep-link messageId, refus ROLE_CLIENT 2026-05-20 00:37:03 +02:00
67e73a52d7 feat(mail) : MailMessageViewer — header, body sanitizé DOMPurify, PJ téléchargeables, 4 actions 2026-05-20 00:36:29 +02:00
aa175063dc feat(mail) : MailMessageList — liste paginée infinite scroll, indicateurs lu/étoilé/PJ/date relative 2026-05-20 00:35:47 +02:00
9aa14d38a9 feat(mail) : MailFolderTree — arbre récursif dossiers, badges unread, icônes système 2026-05-20 00:35:19 +02:00
95a98012ad feat(mail) : MailRefreshButton — bouton sync manuel, disabled pendant syncing 2026-05-20 00:34:59 +02:00
535753b189 feat(mail) : composable useSystemFolderLabel — mapping dossiers système IMAP vers i18n + icônes 2026-05-20 00:34:46 +02:00
e710f57c49 feat(mail) : clés i18n mail.* (titres, vides, dossiers système, actions, erreurs) 2026-05-20 00:34:24 +02:00
73f0adc761 docs(mail) : plan détaillé Phase 5 — page /mail 3 colonnes + 4 composants (9 tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:32:34 +02:00
2e0f5b4e30 feat(mail) : store Pinia useMailStore — folders, messages, polling 30s, markRead/markFlagged 2026-05-20 00:26:19 +02:00
33e4e79f8e feat(mail) : service API mail — listFolders/messages/getMessage/markRead/markFlagged/createTask/linkTask/downloadAttachment/triggerSync 2026-05-20 00:25:21 +02:00
bfa155d060 feat(mail) : helper sanitizeMailHtml — DOMPurify + placeholder images distantes 2026-05-20 00:24:30 +02:00
e7224765b1 feat(mail) : types TS DTOs mail (config, folders, messages, attachments) 2026-05-20 00:23:26 +02:00
fe07398059 feat(mail) : install dompurify + types 2026-05-20 00:22:33 +02:00
a440ce267f docs(mail) : plan détaillé Phase 4 — services TS, store Pinia, DOMPurify (6 tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:21:50 +02:00
8986f3cb0e feat(mail) : security.yaml - access_control ^/api/mail (IS_AUTHENTICATED_FULLY)
- ajoute la regle ^/api/mail avant ^/api pour expliciter l'authentification requise
- les checks fins ROLE_USER vs ROLE_CLIENT restent dans MailAccessChecker (chaque controller)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:15:49 +02:00
6d420c86e8 feat(mail) : MailSyncTriggerController - POST /api/mail/sync (202 + Messenger async)
- dispatch MailSyncRequested au bus Messenger, retourne 202 immediat
- folderPath optionnel via body JSON pour sync ciblee
- en test : transport in-memory route le message en sync
- securite via MailAccessChecker

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:15:25 +02:00
cc46dd915d feat(mail) : MailSyncRequested message + handler + messenger.yaml transport async Doctrine
- App\Message\MailSyncRequested (optionnel folderPath)
- App\MessageHandler\MailSyncRequestedHandler delegue a MailSyncService::syncFolder ou syncAll
- messenger.yaml : transport async via Doctrine DSN, retry 3x exponentiel, failure transport
- en test : transport in-memory (sync immediat)
- migration Version20260519220000 : cree messenger_messages table (idempotente, IF NOT EXISTS)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:14:47 +02:00
f7f7a07162 feat(mail) : MailAttachmentDownloadController - GET /api/mail/attachments/{id} (stream, disposition: attachment)
- downloadId = base64url(messageDbId:partNumber)
- Content-Disposition: attachment systematique (jamais inline pour eviter XSS via HTML attachments)
- X-Content-Type-Options: nosniff
- filename sanitise via basename pour eviter path traversal

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:12:38 +02:00
117175d4b1 feat(mail) : MailLinkTask + MailUnlinkTask + TaskMailsList controllers
- POST /api/mail/messages/{id}/link-task body {taskId} : cree TaskMailLink (idempotent)
- DELETE /api/mail/messages/{id}/link-task/{taskId} : supprime le lien (204)
- GET /api/tasks/{id}/mails : liste les mails lies a une tache
- securite via MailAccessChecker, tests fonctionnels 401/403

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:12:10 +02:00
c7d12f6acd feat(mail) : MailCreateTaskController - POST /api/mail/messages/{id}/create-task
- cree une Task avec titre = subject du mail (max 255 chars)
- utilise findMaxNumberByProjectForUpdate pour numero (advisory lock PG)
- transaction wrapInTransaction pour eviter race conditions
- taskGroupId et priorityId optionnels via body JSON
- cree automatiquement le TaskMailLink (mail <-> tache)
- retourne 201 + taskId/taskNumber/taskTitle/messageId

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:10:53 +02:00
f584ed96fa feat(mail) : MailMessageReadController + MailMessageFlagController - POST .../read et .../flag
- POST /api/mail/messages/{id}/read body {read: bool} - synchro IMAP + BDD
- POST /api/mail/messages/{id}/flag body {flagged: bool} - synchro IMAP + BDD
- IMAP-side non bloquant : BDD est mise a jour meme si IMAP fail (resync au prochain cycle)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:10:06 +02:00
5ce7693343 feat(mail) : MailMessageDetailController - GET /api/mail/messages/{id} (live IMAP + cache 5 min)
- recupere headers + body + attachments via ImapMailProvider::fetchMessage
- cache Symfony pool cache.app, cle mail_body_{md5(messageId)}, TTL 300s
- attachments serialises sans contenu binaire, avec downloadId base64url(messageDbId:partNumber)
- 503 si IMAP indisponible, 404 si message inconnu
- les tests read/flag ROLE_CLIENT/auth seront ajoutes en Task 10 (route deja existante)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:09:30 +02:00
7fb525595e feat(mail) : MailMessagesListController - GET /api/mail/folders/{path}/messages (pagination cursor)
- MailMessageRepository::findByFolderCursor : pagination cursor sentAt DESC, id DESC
- cursor base64url(sentAt_iso:id), limit max 100
- folderPath URL-encode (requirements: .+ pour supporter les slashes nested)
- securite via MailAccessChecker

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:08:18 +02:00
b1d6303afe feat(mail) : MailFoldersListController - GET /api/mail/folders (arbre BDD + unreadCount)
- lit la BDD (pas l'IMAP live), retourne l'arbre des dossiers avec metadata
- securite via MailAccessChecker : ROLE_USER/ADMIN, refus ROLE_CLIENT pur
- tests fonctionnels 401/403/200

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:07:23 +02:00
1c3ba9c33c feat(mail) : MailAccessChecker - verification acces mail ROLE_USER/ROLE_ADMIN (refus ROLE_CLIENT pur)
- ensureCanAccessMail : refuse ROLE_CLIENT pur (sans ROLE_ADMIN)
- ensureIsAdmin : helper pour endpoints config
- service utilise par tous les controllers metier mail

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:06:45 +02:00
412c412cbc feat(mail) : MailTestConnectionController — POST /api/mail/configuration/test
- endpoint ROLE_ADMIN qui teste la connexion IMAP via listFolders
- retourne ok:bool + foldersCount ou error sanitise (pas de leak interne)
- priority:1 obligatoire pour eviter conflit avec route API Platform {id}

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:06:25 +02:00
62e0bf5f11 feat(mail) : MailSettings ApiResource singleton (GET/PATCH /api/mail/configuration)
- ApiResource MailSettings expose les operations Get + Patch sur /api/mail/configuration
- Provider + Processor relient le DTO a l'entite MailConfiguration (singleton)
- password en write-only (jamais retourne) + hasPassword en lecture
- chiffrement password via TokenEncryptor (sodium)
- securite ROLE_ADMIN sur les deux operations

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:06:01 +02:00
696b40ca80 feat(mail) : install symfony/messenger + browser-kit + ENCRYPTION_KEY test (deps Phase 3)
- ajoute symfony/messenger ^8.0 et symfony/doctrine-messenger ^8.0 pour la sync mail async
- ajoute symfony/browser-kit + css-selector en dev pour tests fonctionnels WebTestCase
- ENCRYPTION_KEY ajoutee dans phpunit.dist.xml pour permettre le chiffrement en test
- MESSENGER_TRANSPORT_DSN configure (Doctrine), messenger.yaml minimal (sera enrichi en Task 12)
- fix(orm) : ClientTicket - migre uniqueConstraints en attribut separe (Doctrine ORM 4 deprecation)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 00:05:49 +02:00
cbbc491d69 docs(mail) : plan détaillé Phase 3 — API endpoints, sécurité ROLE_CLIENT, Messenger async (15 tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:48:43 +02:00
26fab44dab docs(mail) : guide configuration cron OS pour mail-sync 2026-05-19 23:39:44 +02:00
0028b489e4 feat(mail) : Makefile — target mail-sync avec options FOLDER et DRYRUN 2026-05-19 23:39:21 +02:00
1fb7460f8e feat(mail) : commande app:mail:sync avec options --folder et --dry-run 2026-05-19 23:38:40 +02:00
c47434b502 feat(mail) : MailSyncService — syncAll/syncFolder/syncFolderStructure + lock + garde 50% 2026-05-19 23:37:31 +02:00
f245863b78 feat(mail) : MailMessageRepository — findMaxUidInFolder, findLastNByFolder, findAllUidsByFolder 2026-05-19 23:35:30 +02:00
b546f528df feat(mail) : ImapMailProvider — implémentation complète MailProviderInterface 2026-05-19 23:35:12 +02:00
b5b4288cc0 feat(mail) : DTO MailSyncReport + test unitaire 2026-05-19 23:32:29 +02:00
3a2d8d5bde feat(mail) : install webklex/php-imap + symfony/lock, configure lock store 2026-05-19 23:32:01 +02:00
23191bdab6 docs(mail) : plan détaillé Phase 2 — ImapMailProvider, MailSyncService, commande app:mail:sync (9 tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:29:18 +02:00
5f92cbbf4f feat(mail) : fixture MailConfiguration OVH defaults (disabled) 2026-05-19 23:22:05 +02:00
f80680e874 feat(mail) : MailProviderInterface + MailProviderException 2026-05-19 23:20:58 +02:00
697197864f feat(mail) : DTOs — MailFolderDto, MailMessageHeaderDto, MailAttachmentDto, MailMessageDetailDto 2026-05-19 23:20:35 +02:00
0da26ff418 feat(mail) : migration — 4 tables mail_configuration, mail_folder, mail_message, task_mail_link 2026-05-19 23:20:03 +02:00
cd9c16a990 feat(mail) : TaskMailLink entity + repository 2026-05-19 23:17:16 +02:00
0c597bc653 feat(mail) : MailMessage entity + repository 2026-05-19 23:16:52 +02:00
0c80159d7e feat(mail) : MailFolder entity + repository 2026-05-19 23:16:17 +02:00
3cac87aa24 feat(mail) : MailConfiguration entity + repository + singleton test 2026-05-19 23:15:47 +02:00
07b7d054d5 docs(mail) : plan détaillé Phase 1 — entités, repos, migration, DTOs, interface (10 tasks TDD)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:10:36 +02:00
361cc8cfab docs(mail) : master plan d'intégration mail OVH IMAP — 7 phases (foundations, sync, API, services front, UI, intégration tâches, admin)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:49:39 +02:00
930e1a1e37 fix(help) : retire definePageMeta auth (middleware global déjà appliqué) 2026-05-19 21:12:05 +02:00
55301c9c63 feat(help) : centre d'aide in-app — page /help avec sidebar + 9 sections markdown stylées, icône ? dans la topbar 2026-05-19 21:09:19 +02:00
5fb7fbe66c fix(workflow) : M4 - aligne la séquence workflow.id après recréation de l'identity (évite conflit avec row Standard de M1) 2026-05-19 20:59:37 +02:00
c1560468e6 fix(workflow) : WorkflowDrawer - input position natif (MalioInputText n'accepte pas les number) 2026-05-19 20:59:37 +02:00
f86698e7cd docs(workflows) : plan d'implémentation + validations Matthieu sur le spec + gitignore dumps locaux 2026-05-19 20:59:37 +02:00
1fd2c05db3 chore : bump version to v0.4.0 2026-05-19 20:59:37 +02:00
9f179e400d feat(workflow) : MCP - list-statuses projectId + list-workflows + switch-project-workflow + maj descriptions create/update-task 2026-05-19 20:59:12 +02:00
6a37349cf7 feat(workflow) : bulk status désactivé sur sélection multi-projets, scoped au workflow du projet 2026-05-19 20:59:12 +02:00
52b78d6bbc feat(workflow) : ProjectWorkflowSwitchModal + section workflow et bouton switch dans ProjectDrawer 2026-05-19 20:59:12 +02:00
e6d765f7bb feat(workflow) : my-tasks - kanban groupé par catégorie avec badge statut, suppression drag-to-status 2026-05-19 20:59:12 +02:00
5d42009348 feat(workflow) : kanban projet et archives basés sur workflow.statuses du projet 2026-05-19 20:59:12 +02:00
8e4ddf00a8 feat(workflow) : admin UI - WorkflowDrawer + AdminWorkflowTab + remplacement onglet Statuts, suppression composants obsolètes 2026-05-19 20:59:12 +02:00
18bc96082f feat(workflow) : DTOs front Workflow + category sur TaskStatus + workflow embarqué sur Project + service + i18n 2026-05-19 20:59:12 +02:00
6a084489ea feat(workflow) : endpoint POST /projects/{id}/switch-workflow + processor transactionnel 2026-05-19 20:59:12 +02:00
80a41db34f feat(workflow) : protège la suppression d'un workflow lié à des projets (409) 2026-05-19 20:59:12 +02:00
cf94635121 feat(workflow) : valide que task.status appartient au workflow du projet 2026-05-19 20:59:12 +02:00
eec61c089c feat(workflow) : migration M4 - alignement schéma Doctrine (indexes + IDENTITY) 2026-05-19 20:59:12 +02:00
a9f87be8e5 feat(workflow) : listener garantissant un seul workflow isDefault=true 2026-05-19 20:59:12 +02:00
25f2fc4b16 feat(workflow) : fixtures - workflow Standard + statuts catégorisés + projets attachés 2026-05-19 20:59:12 +02:00
a21914312a feat(workflow) : migration M3 - workflow requis sur Project (RESTRICT) 2026-05-19 20:59:12 +02:00
f6a947ec15 feat(workflow) : migration M2 - rattache les statuts existants à Standard + category 2026-05-19 20:59:12 +02:00
03f3c85fd8 feat(workflow) : migration M1 - création table workflow + seed Standard 2026-05-19 20:59:12 +02:00
8a68e0d397 feat(workflow) : ajoute workflow requis sur Project (RESTRICT) 2026-05-19 20:59:12 +02:00
43e6d1aed2 feat(workflow) : ajoute workflow et category sur TaskStatus 2026-05-19 20:59:12 +02:00
a3e3fd6da6 feat(workflow) : ajoute l'entité Workflow et son repository 2026-05-19 20:59:12 +02:00
b8b03048b6 feat(workflow) : ajoute l'enum StatusCategory (5 catégories canoniques) 2026-05-19 20:59:12 +02:00
Matthieu
ba86a71e12 docs(workflows) : ajout note de reprise sur autre poste
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:59:12 +02:00
Matthieu
6a942def3f docs(workflows) : spec workflows de statuts par projet
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:59:12 +02:00
gitea-actions
d4fdb84a17 chore: bump version to v0.3.34
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 19s
2026-05-13 14:23:42 +00:00
Matthieu
5585fa7ef6 fix(mcp) : exclude DataFixtures from discovery to avoid require-dev autoload error in prod
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
2026-05-13 16:23:35 +02:00
gitea-actions
b301ebbad0 chore: bump version to v0.3.33
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 52s
2026-05-13 12:59:31 +00:00
Matthieu
feaa9f1875 feat(api-token) : génération du token MCP depuis la page profil
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Backend :
- POST /api/me/regenerate-api-token : nouveau controller, ROLE_USER (exclut CLIENT)
- User.apiToken exposé via groupe me:read sur GET /api/me

Frontend :
- Section 'Token API MCP' sur /profile (masquée pour les CLIENT du portail)
- Boutons Copier + Régénérer avec modal de confirmation
- Service api-token + DTO mis à jour + clés i18n fr
2026-05-13 14:59:18 +02:00
gitea-actions
b25be8fd6a chore: bump version to v0.3.32
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 43s
2026-05-06 13:58:46 +00:00
152 changed files with 24948 additions and 542 deletions

14
.env
View File

@@ -20,4 +20,16 @@ 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
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 ###

6
.gitignore vendored
View File

@@ -30,3 +30,9 @@
###> docker local ###
infra/dev/.env.docker.local
###< docker local ###
###> local db dumps ###
*.sql.gz
*.sql.gz:Zone.Identifier
REVIEW.md
###< local db dumps ###

View File

@@ -2,6 +2,8 @@
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

View File

@@ -21,6 +21,7 @@ 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)
@@ -73,6 +74,7 @@ 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

View File

@@ -21,12 +21,15 @@
"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.*",
@@ -36,7 +39,8 @@
"symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*",
"symfony/validator": "8.0.*",
"symfony/yaml": "8.0.*"
"symfony/yaml": "8.0.*",
"webklex/php-imap": "^6.2"
},
"config": {
"allow-plugins": {
@@ -93,6 +97,8 @@
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.94",
"phpunit/phpunit": "^13.0"
"phpunit/phpunit": "^13.0",
"symfony/browser-kit": "^8.0",
"symfony/css-selector": "^8.0"
}
}

1343
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
framework:
lock: '%env(LOCK_DSN)%'

View File

@@ -21,3 +21,6 @@ mcp:
store: file
directory: '%kernel.project_dir%/var/mcp-sessions'
ttl: 3600
discovery:
scan_dirs: ['src']
exclude_dirs: ['DataFixtures']

View File

@@ -0,0 +1,33 @@
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://'

View File

@@ -64,6 +64,8 @@ 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:

View File

@@ -0,0 +1,5 @@
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
providers:

View File

@@ -301,7 +301,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* },
* translator?: bool|array{ // Translator configuration
* enabled?: bool|Param, // Default: false
* enabled?: bool|Param, // Default: true
* 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: false
* enabled?: bool|Param, // Default: true
* 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: false
* enabled?: bool|Param, // Default: true
* 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: false
* enabled?: bool|Param, // Default: true
* },
* elasticsearch?: bool|array{
* enabled?: bool|Param, // Default: false

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.3.31'
app.version: '0.4.3'

111
docs/mail-cron-setup.md Normal file
View File

@@ -0,0 +1,111 @@
# 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.
## Prérequis
- Container `php-lesstime-fpm` démarré (`make start`)
- `MailConfiguration.enabled = true` (configurable depuis l'admin — Phase 7)
- `ENCRYPTION_KEY` défini dans `infra/dev/.env.docker.local` (ou production env)
## Installation du cron
Sur la **machine hôte** (pas dans le container) :
```bash
crontab -e
```
Ajouter la ligne suivante (adapter le chemin) :
```cron
*/10 * * * * cd /home/r-dev/malio-dev/Lesstime && make mail-sync >> /var/log/lesstime-mail-sync.log 2>&1
```
Ou directement via `docker exec` (sans dépendance à `make`) :
```cron
*/10 * * * * docker exec php-lesstime-fpm php bin/console app:mail:sync >> /var/log/lesstime-mail-sync.log 2>&1
```
### Avec un utilisateur système dédié
Si le cron est configuré pour un utilisateur système spécifique (ex: `www-data` ou `deploy`) :
```bash
sudo crontab -u deploy -e
```
## 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é doit être la même que celle utilisée pour chiffrer le password lors de la configuration.
## 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`)
## Commandes utiles
```bash
# Sync complète (toutes les boîtes)
make mail-sync
# Sync d'un seul dossier (le dossier doit déjà exister en base)
make mail-sync FOLDER=INBOX
# Simulation (dry-run, pas d'écriture BDD)
make mail-sync DRYRUN=1
# Directement dans le container
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
Les logs Symfony sont dans `var/log/dev.log` (ou `prod.log` en production).
Suivre les logs en temps réel :
```bash
make logs-dev
```
Les messages loggés par `MailSyncService` sont préfixés `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`)
## 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é
## Production
En production, préférer un cron système ou un job scheduler (Kubernetes CronJob, ECS Scheduled Task, etc.).
La commande est idempotente : relancer plusieurs fois ne duplique pas les données (UIDs uniques en base).

147
docs/mail-integration.md Normal file
View File

@@ -0,0 +1,147 @@
# 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) |

View File

@@ -0,0 +1,264 @@
# 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

View File

@@ -0,0 +1,526 @@
# 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,224 @@
# Workflows de statuts par projet (Kanban custom)
**Date** : 2026-05-19
**Branche** : `feat/project-workflows`
**Statut** : design validé (2026-05-19, par Matthieu), en attente de plan d'implémentation
## Reprise sur un autre poste
> **Pour le prochain Claude qui ouvre cette branche :**
>
> 1. Branche `feat/project-workflows` checkout-ée, basée sur `develop` (commit `5585fa7` à l'origine).
> 2. **Ce qui est fait** : design validé avec Matthieu et committé (ce fichier).
> 3. **Aucun code applicatif n'a encore été écrit.**
> 4. **Prochaine étape** : invoquer la skill `superpowers:writing-plans` pour transformer ce design en plan d'implémentation détaillé (découpage en tickets ordonnés, dépendances, estimations).
> 5. **Validations Matthieu (2026-05-19)** :
> - Hors scope (§8) → MCP `switch-project-workflow` **rapatrié dans la V1** (cf. §6).
> - Fallback `in_progress` pour statuts non-mappables → **abandonné**. Seuls les 5 statuts standards existent ; la migration M2 échoue explicitement si elle rencontre autre chose.
> - Suppression d'`AdminStatusTab` → **OK**.
> - Ordre des étapes de livraison (§10) → **OK**.
> 6. **Time tracking** : créer un nouveau timer Lesstime au reprise (projet=5 Lesstime, tags=[3 Backend, 9 Gestion projet]).
> 7. **Fichiers déjà modifiés sur develop (orphelins, pas liés à cette feature)** à ne PAS toucher : `.mcp.json`, `config/reference.php`, `frontend/package-lock.json`, `frontend/pages/profile.vue`.
## 1. Contexte et besoin
Aujourd'hui les `TaskStatus` sont globaux : tous les projets partagent le même jeu de 5 statuts (À faire / En cours / Bloqué / En attente de validation / Terminé). Pour les gros projets de dev, on veut pouvoir définir un kanban plus riche (ex : Backlog / To Do / In Dev / Code Review / QA / Blocked / Ready to deploy / Done) sans imposer ce détail aux projets simples.
**Objectif** : permettre à chaque projet d'avoir son propre jeu de colonnes kanban, via des **templates de workflows réutilisables** définis en admin et assignés à un projet, sans casser les projets existants ni les vues transverses (`my-tasks`, time-tracking, dashboards, MCP).
## 2. Modèle de données
### Nouvelle entité : `Workflow`
```
Workflow
- id int, PK
- name string(255), unique
- isDefault bool (un seul = true ; assigné aux projets sans workflow explicite ; unicité garantie par un listener Doctrine PrePersist/PreUpdate)
- position int (pour l'ordre dans l'admin)
- statuses OneToMany → TaskStatus (inverse côté Workflow)
```
### Modifications : `TaskStatus`
```
TaskStatus
+ workflow_id int, FK → Workflow, NOT NULL, onDelete=CASCADE
+ category string, enum PHP : 'todo' | 'in_progress' | 'blocked' | 'review' | 'done', NOT NULL
~ position devient relatif au workflow (idéalement contrainte unique (workflow_id, position))
- isFinal conservé tel quel — distinct de category='done' (permet un statut "Annulé" final ≠ done)
```
### Modifications : `Project`
```
Project
+ workflow_id int, FK → Workflow, NOT NULL, onDelete=RESTRICT
```
### Choix de design
- **Pas de partage de statuts entre workflows** : chaque workflow a SES PROPRES rows `TaskStatus`. "À faire" du workflow Standard ≠ "À faire" de Dev Kanban (IDs et couleurs distincts). Évite les bugs de couplage, simplifie le mapping lors du switch.
- **`category` obligatoire** : pivot pour les vues transverses + mapping auto lors du switch. 5 valeurs : `todo`, `in_progress`, `blocked`, `review`, `done`.
- **Plusieurs statuts peuvent partager la même catégorie** dans un workflow (ex : 3 statuts en `review` dans Dev Kanban). La catégorie n'est pas une contrainte, juste un bucket de regroupement.
- **`onDelete=RESTRICT` sur `Project.workflow_id`** : un workflow ne peut pas être supprimé s'il a au moins un projet attaché. Protection à 3 niveaux (DB / API / UI).
- **Suppression de TaskStatus** : reste protégée comme aujourd'hui via le flow `ConfirmDeleteStatusModal` (réassignation des tâches à un autre statut ou null).
## 3. Migrations BDD
Trois migrations Doctrine successives :
**M1 — `create_workflow_table`**
- Crée la table `workflow` (id, name, is_default, position)
- Insère le workflow par défaut `Standard` (is_default=true, position=0)
**M2 — `add_workflow_to_task_status`**
- Ajoute `task_status.workflow_id` nullable + `task_status.category` nullable
- `UPDATE task_status SET workflow_id = <id Standard>` pour toutes les lignes existantes
- Backfill catégories (uniquement les 5 statuts standards existants — confirmé avec Matthieu 2026-05-19) :
- "À faire" → `todo`
- "En cours" → `in_progress`
- "Bloqué" → `blocked`
- "En attente de validation" → `review`
- "Terminé" → `done`
- La migration **échoue** (exception) si elle rencontre un label non listé → garde-fou explicite contre toute prod qui aurait dérivé.
- Passe les 2 colonnes en `NOT NULL`
**M3 — `add_workflow_to_project`**
- Ajoute `project.workflow_id` nullable
- `UPDATE project SET workflow_id = <id Standard>` pour tous les projets existants
- Passe en `NOT NULL` avec FK `ON DELETE RESTRICT`
## 4. Backend (Symfony / API Platform)
### Entités
- `App\Entity\Workflow` — nouvelle entité, ApiResource avec `ROLE_ADMIN` pour Post/Patch/Delete
- `App\Enum\StatusCategory` — enum PHP avec les 5 valeurs canoniques
- `App\Entity\TaskStatus` — ajout des propriétés `workflow` (ManyToOne) et `category` (StatusCategory)
- `App\Entity\Project` — ajout de la propriété `workflow` (ManyToOne, requise)
### Sérialisation
- Groupe `workflow:read` pour l'API admin
- `task_status:read` ajoute `workflow` et `category`
- `project:read` embarque le workflow (ou son IRI) — décision à arbitrer dans le plan d'impl (vraisemblablement embarqué pour limiter les round-trips)
### Endpoint dédié au switch
```
POST /api/projects/{id}/switch-workflow
Body: {
workflowId: int,
mapping: { "<sourceStatusId>": <targetStatusId> | null, ... }
}
Security: ROLE_ADMIN
```
**Processor** : `App\State\SwitchProjectWorkflowProcessor`
1. Valide qu'il y a une entrée de mapping pour chaque `statusId` actuellement référencé par les tâches du projet (sinon 422 avec liste des sources manquantes)
2. Valide que chaque target appartient bien au workflow cible (ou est `null`)
3. Transaction unique :
- Pour chaque entrée du mapping : `UPDATE task SET status_id = <target> WHERE project_id = X AND status_id = <source>`
- `UPDATE project SET workflow_id = <new>`
4. Retourne `{ project, migratedTaskCount }`
### Validation cross-entity
- Sur `Task` (Post/Patch) : si `status` fourni, valider que `status.workflow === task.project.workflow`. Sinon 422 `"Status does not belong to this project's workflow"`.
### Suppression d'un Workflow
- `WorkflowProcessor` (custom Delete) : compte les projets liés ; si > 0, renvoie 409 Conflict avec `{ linkedProjectIds: [...], message: "Workflow used by N project(s)" }`
## 5. Frontend (Nuxt / Vue)
### Nouveaux fichiers
- `frontend/services/workflows.ts` — service API CRUD
- `frontend/services/dto/workflow.ts` — type TS
- `frontend/components/admin/AdminWorkflowTab.vue` — nouvel onglet dans `/admin`
- `frontend/components/admin/WorkflowDrawer.vue` — drawer création/édition workflow (nom + liste éditable des statuts avec leur catégorie)
- `frontend/components/project/ProjectWorkflowSwitchModal.vue` — modal de migration
### Modifications
- `frontend/components/admin/AdminStatusTab.vue` :
- **Supprimé.** Toute la gestion des statuts passe par l'onglet Workflows (un workflow = nom + sa liste de statuts éditable inline). Évite la confusion "où je crée un statut ?".
- `frontend/components/project/ProjectDrawer.vue` :
- Affiche le workflow actuel
- Bouton "Changer de workflow" qui ouvre `ProjectWorkflowSwitchModal`
- `frontend/pages/projects/[id]/index.vue` :
- Charge `project.workflow.statuses` au lieu de `statusService.getAll()`
- Le kanban a les colonnes du workflow du projet
- `frontend/pages/projects/[id]/archives.vue` :
- Filtre statut limité au workflow du projet
- `frontend/pages/my-tasks.vue` :
- **Kanban groupé par catégorie** : 5 colonnes (Todo / In Progress / Blocked / Review / Done)
- Chaque card affiche le statut spécifique en badge
- Vue liste : pas de changement
- `frontend/components/task/TaskModal.vue` :
- Reçoit `:statuses` filtrés par workflow du projet via les pages parentes (déjà la pattern actuelle)
- `frontend/components/task/TaskBulkActions.vue` :
- Dropdown statut filtré au workflow du projet de la tâche sélectionnée
- Si tâches multi-projets : bouton "Changer le statut" désactivé avec tooltip explicatif
### `ProjectWorkflowSwitchModal.vue` — détails UX
- Étape 1 : `MalioSelect` des workflows disponibles (sauf le workflow actuel)
- Étape 2 (après sélection) : tableau de mapping
- Une ligne par statut source effectivement utilisé par les tâches du projet (count > 0) + une ligne "Backlog" si des tâches `status=null`
- Colonnes : Source (label + badge catégorie) → Cible (`MalioSelect` des statuts du workflow cible, pré-rempli intelligemment) → Nb de tâches concernées
- Pré-remplissage : pour chaque source, on cherche dans le workflow cible le statut de **même catégorie** avec la plus petite `position`. Si aucune correspondance par catégorie, l'utilisateur doit choisir manuellement.
- Option "Mapper vers le backlog" sur chaque ligne (= cible `null`)
- Footer :
- Bouton "Confirmer la migration" désactivé tant qu'au moins un mapping est manquant
- Toast au succès : "N tâches migrées, projet sur workflow '<nom>'"
## 6. MCP
| Tool | Changement |
|---|---|
| `list-statuses` | Ajout d'un param optionnel `projectId?: int`. Si fourni → renvoie les statuts du workflow du projet. Sinon → renvoie tous les statuts avec `workflowId` et `category` ajoutés. Description mise à jour pour mentionner les workflows. |
| `list-workflows` (nouveau) | Liste tous les workflows avec leurs statuts groupés (`{ id, name, isDefault, statuses: [...] }`). |
| `create-task` / `update-task` | La validation backend (côté entité Task) rejette automatiquement un `status` n'appartenant pas au workflow du projet. Documenter dans la description du tool. |
| `switch-project-workflow` (nouveau, ROLE_ADMIN) | Wrappe l'endpoint `POST /api/projects/{id}/switch-workflow`. Params : `projectId`, `workflowId`, `mapping: { [sourceStatusId]: targetStatusId \| null }`. Renvoie `{ migratedTaskCount }`. Mêmes validations que l'endpoint HTTP. |
## 7. Permissions
| Action | Rôle requis |
|---|---|
| Lire les workflows et leurs statuts | `ROLE_USER` |
| Créer / éditer / supprimer un workflow | `ROLE_ADMIN` |
| Créer / éditer / supprimer un statut | `ROLE_ADMIN` |
| Changer le workflow d'un projet (switch) | `ROLE_ADMIN` |
## 8. Hors scope (YAGNI explicites)
- **Workflows en read-only intégrés** (ex : "Scrum officiel" non éditable) — pas besoin pour l'instant
- **Transitions autorisées** entre statuts (ex : impossible de passer de "Backlog" directement à "Done") — pas demandé, ajouterait beaucoup de complexité
- **Versioning des workflows** (historique des modifs) — pas demandé
- **Workflow par groupe de tâches** (TaskGroup avec son propre workflow dans un projet) — pas demandé
## 9. Risques et limites
- **Migration M2 (backfill catégories)** : la migration échoue si elle rencontre un label de statut autre que les 5 standards. Si la prod a dérivé entre temps, ajouter le mapping manuellement à la migration avant déploiement.
- **`my-tasks` kanban groupé** : avec des projets multi-workflows, l'utilisateur voit une card "In Dev" et une card "En cours" dans la même colonne `in_progress`. Le badge statut sur la card doit rester lisible (taille suffisante, couleur du statut).
- **Filtre statut dans `my-tasks` (vue liste)** : aujourd'hui pas de filtre statut côté `my-tasks` (cf. code), donc rien à adapter. Si on en ajoute un plus tard, il faudra qu'il propose les catégories plutôt que les statuts spécifiques.
- **Sélection multi-projets dans `TaskBulkActions`** : le bouton "Changer de statut" se désactive ; à valider que le reste du bulk reste utilisable (assignee, priorité, effort, group — eux restent globaux ou per-project comme aujourd'hui).
## 10. Étapes de livraison suggérées
1. Migrations BDD + entité `Workflow` + enum `StatusCategory` + adaptations entités `TaskStatus` et `Project`
2. Validation cross-entity sur `Task` + sérialisation des nouvelles propriétés
3. Endpoint `POST /api/projects/{id}/switch-workflow` + processor
4. Service frontend `workflows` + types DTO
5. UI admin : `AdminWorkflowTab` + `WorkflowDrawer`
6. Adaptation `projects/[id]/index.vue` (kanban filtré par workflow)
7. Adaptation `my-tasks.vue` (kanban groupé par catégorie)
8. `ProjectWorkflowSwitchModal` + intégration dans `ProjectDrawer`
9. Adaptation `TaskBulkActions` et autres écrans transverses
10. MCP : modification `list-statuses` + nouveaux `list-workflows` et `switch-project-workflow` + mise à jour des descriptions
11. Tests : PHPUnit pour le processor + validation cross-entity ; tests fonctionnels du switch (HTTP + MCP)
Le découpage exact (tickets, ordre, dépendances) sera fait dans le plan d'implémentation.

View File

@@ -0,0 +1,231 @@
<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>

View File

@@ -1,140 +0,0 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">Statuts</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
label="Ajouter un statut"
@click="openCreate"
/>
</div>
<DataTable
:columns="columns"
:items="items"
:loading="isLoading"
empty-message="Aucun statut trouvé."
deletable
@row-click="openEdit"
@delete="requestDelete"
>
<template #cell-color="{ item }">
<span
class="inline-block h-6 w-6 rounded-full"
:style="{ backgroundColor: item.color }"
/>
</template>
</DataTable>
<TaskStatusDrawer
v-model="drawerOpen"
:item="selectedItem"
@saved="onSaved"
/>
<ConfirmDeleteStatusModal
v-model="confirmModalOpen"
:status-label="statusToDelete?.label ?? ''"
:task-count="affectedTaskCount"
:available-statuses="reassignTargets"
@confirm="onConfirmDelete"
/>
</div>
</template>
<script setup lang="ts">
import type { TaskStatus } from '~/services/dto/task-status'
import type { Task } from '~/services/dto/task'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskService } from '~/services/tasks'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const columns: DataTableColumn[] = [
{ key: 'label', label: 'Libellé', primary: true },
{ key: 'color', label: 'Couleur' },
{ key: 'position', label: 'Position', class: 'text-neutral-700' },
]
const statusService = useTaskStatusService()
const taskService = useTaskService()
const items = ref<TaskStatus[]>([])
const tasks = ref<Task[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<TaskStatus | null>(null)
const confirmModalOpen = ref(false)
const statusToDelete = ref<TaskStatus | null>(null)
const affectedTaskCount = computed(() => {
if (!statusToDelete.value) return 0
return tasks.value.filter(t => t.status?.id === statusToDelete.value!.id).length
})
const reassignTargets = computed(() => {
if (!statusToDelete.value) return items.value
return items.value.filter(s => s.id !== statusToDelete.value!.id)
})
async function loadItems() {
isLoading.value = true
try {
const [statuses, allTasks] = await Promise.all([
statusService.getAll(),
taskService.getAll(),
])
items.value = statuses
tasks.value = allTasks
} finally {
isLoading.value = false
}
}
function openCreate() {
selectedItem.value = null
drawerOpen.value = true
}
function openEdit(item: TaskStatus) {
selectedItem.value = item
drawerOpen.value = true
}
async function requestDelete(item: TaskStatus) {
statusToDelete.value = item
const count = tasks.value.filter(t => t.status?.id === item.id).length
if (count === 0) {
await statusService.remove(item.id)
await loadItems()
} else {
confirmModalOpen.value = true
}
}
async function onConfirmDelete(targetStatusId: number | null) {
if (!statusToDelete.value) return
const affectedTasks = tasks.value.filter(t => t.status?.id === statusToDelete.value!.id)
const statusIri = targetStatusId ? `/api/task_statuses/${targetStatusId}` : null
await Promise.all(
affectedTasks.map(t => taskService.update(t.id, { status: statusIri }))
)
await statusService.remove(statusToDelete.value.id)
confirmModalOpen.value = false
statusToDelete.value = null
await loadItems()
}
async function onSaved() {
await loadItems()
}
onMounted(() => {
loadItems()
})
</script>

View File

@@ -0,0 +1,100 @@
<template>
<div>
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('workflows.title') }}</h2>
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('workflows.addWorkflow')"
@click="openCreate"
/>
</div>
<DataTable
:columns="columns"
:items="items"
:loading="isLoading"
empty-message="Aucun workflow trouvé."
deletable
@row-click="openEdit"
@delete="requestDelete"
>
<template #cell-isDefault="{ item }">
<span
v-if="item.isDefault"
class="rounded bg-primary-100 px-2 py-0.5 text-xs font-medium text-primary-700"
>
{{ $t('workflows.isDefault') }}
</span>
</template>
<template #cell-statusCount="{ item }">
{{ item.statuses.length }}
</template>
</DataTable>
<WorkflowDrawer
v-model="drawerOpen"
:item="selectedItem"
@saved="onSaved"
/>
</div>
</template>
<script setup lang="ts">
import type { Workflow } from '~/services/dto/workflow'
import { useWorkflowService } from '~/services/workflows'
import type { DataTableColumn } from '~/components/ui/DataTable.vue'
const { t } = useI18n()
const columns: DataTableColumn[] = [
{ key: 'name', label: t('workflows.name'), primary: true },
{ key: 'isDefault', label: t('workflows.isDefault') },
{ key: 'statusCount', label: t('workflows.statuses') },
{ key: 'position', label: 'Position' },
]
const workflowService = useWorkflowService()
const items = ref<Workflow[]>([])
const isLoading = ref(true)
const drawerOpen = ref(false)
const selectedItem = ref<Workflow | null>(null)
async function loadItems() {
isLoading.value = true
try {
items.value = await workflowService.getAll()
} finally {
isLoading.value = false
}
}
function openCreate() {
selectedItem.value = null
drawerOpen.value = true
}
function openEdit(item: Workflow) {
selectedItem.value = item
drawerOpen.value = true
}
async function requestDelete(item: Workflow) {
try {
await workflowService.remove(item.id)
await loadItems()
} catch {
// Toast d'erreur déjà émis par useApi
}
}
async function onSaved() {
await loadItems()
}
onMounted(() => {
loadItems()
})
</script>

View File

@@ -0,0 +1,261 @@
<template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('workflows.editWorkflow') : $t('workflows.addWorkflow')">
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<MalioInputText
v-model="form.name"
:label="$t('workflows.name')"
input-class="w-full"
:error="touched.name && !form.name.trim() ? $t('workflows.name') + ' requis' : ''"
@blur="touched.name = true"
/>
<div class="flex items-center gap-2">
<input
id="isDefault"
v-model="form.isDefault"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
<label for="isDefault" class="text-sm font-medium text-neutral-700">
{{ $t('workflows.isDefault') }}
</label>
</div>
<div class="mt-2">
<div class="flex items-center justify-between">
<h3 class="text-sm font-bold text-neutral-900">{{ $t('workflows.statuses') }}</h3>
<MalioButton
type="button"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-3 py-1 text-xs"
:label="$t('workflows.addStatus')"
@click="addStatus"
/>
</div>
<div class="mt-3 flex flex-col gap-3">
<div
v-for="(s, idx) in form.statuses"
:key="idx"
class="rounded border border-neutral-200 p-3"
>
<div class="flex items-end gap-2">
<MalioInputText
v-model="s.label"
label="Libellé"
input-class="w-full"
/>
<select
v-model="s.category"
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"
aria-label="Supprimer"
@click="removeStatus(idx)"
>
<Icon name="mdi:delete" size="20" />
</button>
</div>
<div class="mt-2 flex items-center gap-3">
<ColorPicker v-model="s.color" />
<label class="ml-auto flex items-center gap-1 text-xs text-neutral-700">
<input v-model="s.isFinal" type="checkbox" class="h-3 w-3" />
{{ $t('archive.statusFinal') }}
</label>
<label class="flex flex-col text-xs text-neutral-700">
Position
<input
v-model.number="s.position"
type="number"
class="mt-1 h-9 w-16 rounded border border-neutral-300 px-2 text-sm"
/>
</label>
</div>
</div>
</div>
</div>
<div class="mt-4 flex justify-end">
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Workflow, StatusCategory } from '~/services/dto/workflow'
import type { TaskStatusWrite } from '~/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows'
import { useTaskStatusService } from '~/services/task-statuses'
const { t } = useI18n()
const props = defineProps<{
modelValue: boolean
item: Workflow | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: v => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.item)
const isSubmitting = ref(false)
type StatusForm = {
id?: number
label: string
color: string
position: number
isFinal: boolean
category: StatusCategory
}
const form = reactive<{
name: string
isDefault: boolean
statuses: StatusForm[]
}>({
name: '',
isDefault: false,
statuses: [],
})
const touched = reactive({ name: false })
const categoryOptions: { value: StatusCategory, label: string }[] = [
{ value: 'todo', label: t('workflows.categories.todo') },
{ value: 'in_progress', label: t('workflows.categories.in_progress') },
{ value: 'blocked', label: t('workflows.categories.blocked') },
{ value: 'review', label: t('workflows.categories.review') },
{ value: 'done', label: t('workflows.categories.done') },
]
watch(() => props.modelValue, (open) => {
if (!open) return
if (props.item) {
form.name = props.item.name
form.isDefault = props.item.isDefault
form.statuses = props.item.statuses.map(s => ({
id: s.id,
label: s.label,
color: s.color,
position: s.position,
isFinal: s.isFinal,
category: s.category,
}))
} else {
form.name = ''
form.isDefault = false
form.statuses = []
}
touched.name = false
})
function addStatus() {
form.statuses.push({
label: '',
color: '#222783',
position: form.statuses.length,
isFinal: false,
category: 'todo',
})
}
function removeStatus(idx: number) {
form.statuses.splice(idx, 1)
}
const workflowService = useWorkflowService()
const statusService = useTaskStatusService()
async function handleSubmit() {
touched.name = true
if (!form.name.trim()) return
isSubmitting.value = true
try {
if (isEditing.value && props.item) {
await workflowService.update(props.item.id, {
name: form.name.trim(),
isDefault: form.isDefault,
position: props.item.position,
})
await syncStatuses(props.item)
} else {
const created = await workflowService.create({
name: form.name.trim(),
isDefault: form.isDefault,
position: 0,
})
for (const s of form.statuses) {
const payload: TaskStatusWrite = {
label: s.label,
color: s.color,
position: s.position,
isFinal: s.isFinal,
category: s.category,
workflow: `/api/workflows/${created.id}`,
}
await statusService.create(payload)
}
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
async function syncStatuses(workflow: Workflow) {
const existingIds = new Set(workflow.statuses.map(s => s.id))
const keptIds = new Set<number>()
for (const s of form.statuses) {
if (s.id) {
keptIds.add(s.id)
await statusService.update(s.id, {
label: s.label,
color: s.color,
position: s.position,
isFinal: s.isFinal,
category: s.category,
})
} else {
await statusService.create({
label: s.label,
color: s.color,
position: s.position,
isFinal: s.isFinal,
category: s.category,
workflow: `/api/workflows/${workflow.id}`,
})
}
}
for (const id of existingIds) {
if (id && !keptIds.has(id)) {
await statusService.remove(id)
}
}
}
</script>

View File

@@ -0,0 +1,251 @@
<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 { TaskPriority } from '~/services/dto/task-priority'
import { useMailService } from '~/services/mail'
import { useProjectService } from '~/services/projects'
import { useTaskGroupService } from '~/services/task-groups'
import { useTaskPriorityService } from '~/services/task-priorities'
const props = defineProps<{
/** v-model: true = modal ouvert */
modelValue: boolean
/** ID BDD du message source */
messageId: number
/** Détail du message (pour afficher sujet/expéditeur en lecture seule) */
messageDetail: MailMessageDetailDto | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
/** Émis après création réussie — payload = tâche créée */
created: [task: Task]
}>()
const { t } = useI18n()
const mailService = useMailService()
const projectService = useProjectService()
const taskGroupService = useTaskGroupService()
const priorityService = useTaskPriorityService()
// ─── État formulaire ──────────────────────────────────────────────────────
const projectId = ref<number | null>(null)
const taskGroupId = ref<number | null>(null)
const priorityId = ref<number | null>(null)
const isSubmitting = ref(false)
const touchedProject = ref(false)
// ─── Données de référence ─────────────────────────────────────────────────
const projects = ref<Project[]>([])
const groups = ref<TaskGroup[]>([])
const priorities = ref<TaskPriority[]>([])
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 priorityOptions = computed(() =>
priorities.value.map(p => ({ label: p.label, value: p.id })),
)
// ─── Chargement initial ───────────────────────────────────────────────────
onMounted(async () => {
const [projs, prios] = await Promise.all([
projectService.getAll({ archived: false }),
priorityService.getAll(),
])
projects.value = projs
priorities.value = prios
})
// Recharger les groupes quand le projet change
watch(projectId, async (pid) => {
taskGroupId.value = null
groups.value = []
if (!pid) return
loadingGroups.value = true
try {
groups.value = await taskGroupService.getByProject(pid)
} finally {
loadingGroups.value = false
}
})
// Reset formulaire à l'ouverture
watch(() => props.modelValue, (open) => {
if (open) {
projectId.value = null
taskGroupId.value = null
priorityId.value = null
touchedProject.value = false
}
})
// ─── Actions ──────────────────────────────────────────────────────────────
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,
priority: priorityId.value ? `/api/task_priorities/${priorityId.value}` : undefined,
})
emit('created', task)
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">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<!-- Modal -->
<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.createTaskModal.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-5">
<!-- Info mail source (lecture seule) -->
<div
v-if="messageDetail"
class="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-sm"
>
<p class="font-medium text-neutral-800 truncate">
{{ messageDetail.header.subject ?? t('mail.noSubject') }}
</p>
<p class="mt-0.5 text-xs text-neutral-500 truncate">
{{ messageDetail.header.fromName ?? messageDetail.header.fromEmail }}
</p>
<p class="mt-2 text-xs text-neutral-400 italic">
{{ t('mail.createTaskModal.titleHint') }}
</p>
<p class="text-xs text-neutral-400 italic">
{{ t('mail.createTaskModal.descriptionHint') }}
</p>
</div>
<!-- Sélection projet -->
<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>
<!-- Sélection groupe (optionnel, chargé après projet) -->
<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>
<!-- Sélection priorité (optionnelle) MalioSelect car les values sont number | null -->
<div>
<MalioSelect
v-model="priorityId"
:options="priorityOptions"
:label="t('mail.createTaskModal.priorityLabel')"
:empty-option-label="t('mail.createTaskModal.priorityPlaceholder')"
min-width="w-full"
/>
</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.createTaskModal.submit')"
button-class="w-auto px-6"
:disabled="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>

View File

@@ -0,0 +1,123 @@
<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>

View File

@@ -0,0 +1,266 @@
<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>

View File

@@ -0,0 +1,151 @@
<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>

View File

@@ -0,0 +1,183 @@
<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>

View File

@@ -0,0 +1,228 @@
<script setup lang="ts">
import type { MailMessageHeaderDto } from '~/services/dto/mail'
import { useMailService } from '~/services/mail'
import { useMailStore } from '~/stores/mail'
const props = defineProps<{
modelValue: boolean
/** ID de la tâche cible (destinataire du lien) */
taskId: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
/** Émis après liaison réussie — payload = id du message lié */
linked: [messageId: number]
}>()
const { t } = useI18n()
const mailService = useMailService()
const mailStore = useMailStore()
// ─── État ─────────────────────────────────────────────────────────────────
const searchQuery = ref('')
const allMessages = ref<MailMessageHeaderDto[]>([])
const selectedMessage = ref<MailMessageHeaderDto | null>(null)
const isLoading = ref(false)
const isSubmitting = ref(false)
// ─── Filtrage local (pas d'appel API par frappe — les messages sont déjà chargés) ──
const filteredMessages = computed(() => {
const q = searchQuery.value.toLowerCase().trim()
if (!q) return allMessages.value
return allMessages.value.filter(
(m) =>
(m.subject ?? '').toLowerCase().includes(q)
|| (m.fromName ?? '').toLowerCase().includes(q)
|| (m.fromEmail ?? '').toLowerCase().includes(q),
)
})
// ─── Chargement à l'ouverture ─────────────────────────────────────────────
watch(() => props.modelValue, async (open) => {
if (!open) return
searchQuery.value = ''
selectedMessage.value = null
isLoading.value = true
try {
// Utiliser le dossier actuellement sélectionné dans le store si disponible,
// sinon fallback sur INBOX.
const folderPath = mailStore.selectedFolderPath ?? 'INBOX'
const page = await mailService.listMessages(folderPath, undefined, 50)
allMessages.value = page.items
} finally {
isLoading.value = false
}
})
// ─── Actions ──────────────────────────────────────────────────────────────
function close(): void {
emit('update:modelValue', false)
}
function selectMessage(msg: MailMessageHeaderDto): void {
selectedMessage.value = msg
}
async function handleSubmit(): Promise<void> {
if (!selectedMessage.value) return
isSubmitting.value = true
try {
await mailService.linkTask(selectedMessage.value.id, props.taskId)
emit('linked', selectedMessage.value.id)
close()
} finally {
isSubmitting.value = false
}
}
// ─── Formatage ────────────────────────────────────────────────────────────
function formatDate(iso: string | null): string {
if (!iso) return ''
return new Date(iso).toLocaleDateString('fr', {
day: '2-digit',
month: 'short',
year: 'numeric',
})
}
</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.pickerModal.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">
<!-- Recherche locale -->
<input
v-model="searchQuery"
type="text"
:placeholder="t('mail.pickerModal.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"
/>
<!-- Résultats -->
<div class="max-h-80 overflow-y-auto rounded-md border border-neutral-200 divide-y divide-neutral-100">
<!-- Chargement -->
<div
v-if="isLoading"
class="flex items-center justify-center py-8 text-sm text-neutral-400"
>
<Icon name="material-symbols:progress-activity" size="18" class="mr-2 animate-spin" />
{{ t('mail.pickerModal.loading') }}
</div>
<!-- Vide -->
<div
v-else-if="filteredMessages.length === 0"
class="py-8 text-center text-sm text-neutral-400 italic"
>
{{ t('mail.pickerModal.empty') }}
</div>
<!-- Liste -->
<button
v-for="msg in filteredMessages"
:key="msg.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="selectedMessage?.id === msg.id
? 'bg-primary-50 border-l-2 border-primary-500'
: 'border-l-2 border-transparent'"
@click="selectMessage(msg)"
>
<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">
{{ msg.subject ?? t('mail.noSubject') }}
</p>
<p class="flex items-center gap-2 text-xs text-neutral-500">
<span class="truncate">{{ msg.fromName ?? msg.fromEmail }}</span>
<span class="flex-shrink-0">·</span>
<span class="flex-shrink-0">{{ formatDate(msg.sentAt ?? msg.receivedAt) }}</span>
</p>
</div>
<Icon
v-if="selectedMessage?.id === msg.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.pickerModal.submit')"
button-class="w-auto px-6"
:disabled="!selectedMessage || 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>

View File

@@ -0,0 +1,24 @@
<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>

View File

@@ -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="form.code"
v-model="codeProxy"
label="Code"
input-class="w-full uppercase"
input-class="w-full"
:max-length="10"
:disabled="isEditing"
:error="touched.code && !form.code.trim() ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code.trim()) ? '2 à 10 lettres majuscules' : ''"
:error="touched.code && !form.code ? 'Le code est requis' : touched.code && !/^[A-Z]{2,10}$/.test(form.code) ? '2 à 10 lettres majuscules' : ''"
@blur="touched.code = true"
@input="form.code = form.code.toUpperCase().replace(/[^A-Z]/g, '')"
/>
<MalioInputText
v-model="form.name"
@@ -87,10 +87,35 @@
</MalioButton>
</div>
<div v-if="props.project" class="mt-4 rounded border border-neutral-200 p-3">
<div class="flex items-center justify-between">
<div>
<p class="text-xs uppercase text-neutral-500">{{ $t('workflows.title') }}</p>
<p class="text-sm font-semibold text-neutral-900">{{ props.project.workflow?.name }}</p>
</div>
<MalioButton
v-if="canManageWorkflows"
type="button"
icon-name="mdi:swap-horizontal"
icon-position="left"
button-class="w-auto px-3 py-1 text-xs"
:label="$t('workflows.switchTitle')"
@click="switchModalOpen = true"
/>
</div>
</div>
<ConfirmDeleteProjectModal
v-model="confirmDeleteOpen"
@confirm="handleDelete"
/>
<ProjectWorkflowSwitchModal
v-if="props.project"
v-model="switchModalOpen"
:project="props.project"
@switched="onWorkflowSwitched"
/>
</MalioDrawer>
</template>
@@ -122,6 +147,15 @@ const isOpen = computed({
const isEditing = computed(() => !!props.project)
const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false)
const switchModalOpen = ref(false)
const auth = useAuthStore()
const canManageWorkflows = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
function onWorkflowSwitched() {
emit('saved')
isOpen.value = false
}
const { listRepositories } = useGiteaService()
const giteaRepos = ref<GiteaRepository[]>([])
@@ -152,6 +186,17 @@ 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 }))
)
@@ -188,7 +233,7 @@ async function handleSubmit() {
touched.name = true
touched.code = true
if (!form.name.trim()) return
if (!isEditing.value && (!form.code.trim() || !/^[A-Z]{2,10}$/.test(form.code.trim()))) return
if (!isEditing.value && !/^[A-Z]{2,10}$/.test(form.code)) return
isSubmitting.value = true
try {
@@ -220,7 +265,7 @@ async function handleSubmit() {
if (isEditing.value && props.project) {
await update(props.project.id, payload)
} else {
payload.code = form.code.trim()
payload.code = form.code
await create(payload)
}

View File

@@ -0,0 +1,209 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="close" />
<div class="relative z-10 w-full max-w-2xl rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('workflows.switchTitle') }}</h3>
<div class="mt-5 flex flex-col gap-5">
<MalioSelect
v-model="targetWorkflowId"
:options="targetOptions"
:label="$t('workflows.switchTargetLabel')"
empty-option-label=""
min-width="!w-full"
/>
<div v-if="targetWorkflow" class="flex flex-col gap-2">
<h4 class="text-sm font-bold text-neutral-900">{{ $t('workflows.switchMappingTitle') }}</h4>
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left text-xs text-neutral-500">
<th class="py-2 pr-3">{{ $t('workflows.switchSourceCol') }}</th>
<th class="py-2 pr-3">{{ $t('workflows.switchTargetCol') }}</th>
<th class="py-2 text-right">{{ $t('workflows.switchTaskCountCol') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in mappingRows" :key="row.sourceId ?? 'backlog'" class="border-b last:border-0">
<td class="py-2 pr-3">
<span
v-if="row.source"
class="mr-2 inline-block h-3 w-3 rounded-full align-middle"
:style="{ backgroundColor: row.source.color }"
/>
{{ row.source?.label ?? $t('myTasks.backlog') }}
<span class="ml-1 text-xs text-neutral-400">
({{ row.source?.category ? $t(`workflows.categories.${row.source.category}`) : '—' }})
</span>
</td>
<td class="py-2 pr-3">
<select
v-model="row.targetId"
class="h-9 w-full rounded border border-neutral-300 px-2 text-sm"
>
<option :value="null">{{ $t('workflows.switchToBacklog') }}</option>
<option
v-for="s in targetWorkflow.statuses"
:key="s.id"
:value="s.id"
>
{{ s.label }}
</option>
</select>
</td>
<td class="py-2 text-right text-neutral-700">{{ row.count }}</td>
</tr>
</tbody>
</table>
</div>
<div class="flex justify-end gap-3">
<MalioButton
variant="tertiary"
label="Annuler"
button-class="w-auto px-4"
@click="close"
/>
<MalioButton
:label="$t('workflows.switchConfirm')"
button-class="w-auto px-6"
:disabled="!canConfirm || isSubmitting"
@click="confirm"
/>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { Project } from '~/services/dto/project'
import type { Task } from '~/services/dto/task'
import type { Workflow } from '~/services/dto/workflow'
import type { TaskStatus } from '~/services/dto/task-status'
import { useWorkflowService } from '~/services/workflows'
import { useTaskService } from '~/services/tasks'
const props = defineProps<{
modelValue: boolean
project: Project
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'switched'): void
}>()
const workflows = ref<Workflow[]>([])
const projectTasks = ref<Task[]>([])
const targetWorkflowId = ref<number | null>(null)
const isSubmitting = ref(false)
const workflowService = useWorkflowService()
const taskService = useTaskService()
const targetOptions = computed(() =>
workflows.value
.filter(w => w.id !== props.project.workflow.id)
.map(w => ({ label: w.name, value: w.id })),
)
const targetWorkflow = computed<Workflow | null>(() =>
workflows.value.find(w => w.id === targetWorkflowId.value) ?? null,
)
type Row = {
sourceId: number | null
source: TaskStatus | null
targetId: number | null
count: number
}
const mappingRows = ref<Row[]>([])
function smartPrefill(source: TaskStatus | null, target: Workflow): number | null {
if (!source) return null
const sameCat = target.statuses
.filter(s => s.category === source.category)
.sort((a, b) => a.position - b.position)
return sameCat[0]?.id ?? null
}
watch(targetWorkflow, (tw) => {
if (!tw) {
mappingRows.value = []
return
}
const usedStatusIds = new Map<number | null, number>()
for (const t of projectTasks.value) {
const key = t.status?.id ?? null
usedStatusIds.set(key, (usedStatusIds.get(key) ?? 0) + 1)
}
mappingRows.value = [...usedStatusIds.entries()].map(([sourceId, count]) => {
const source = props.project.workflow.statuses.find(s => s.id === sourceId) ?? null
return {
sourceId,
source,
targetId: smartPrefill(source, tw),
count,
}
})
})
const canConfirm = computed(() => {
if (!targetWorkflow.value) return false
return mappingRows.value.every(r => r.sourceId === null || r.targetId !== undefined)
})
watch(() => props.modelValue, async (open) => {
if (!open) return
targetWorkflowId.value = null
const [allWorkflows, tasks] = await Promise.all([
workflowService.getAll(),
taskService.getFiltered({ project: `/api/projects/${props.project.id}`, archived: false }),
])
workflows.value = allWorkflows
projectTasks.value = tasks
})
function close() {
emit('update:modelValue', false)
}
async function confirm() {
if (!targetWorkflow.value) return
isSubmitting.value = true
try {
const mapping: Record<string, number | null> = {}
for (const r of mappingRows.value) {
if (r.sourceId !== null) {
mapping[String(r.sourceId)] = r.targetId
}
}
await workflowService.switchOnProject(props.project.id, {
workflowId: targetWorkflow.value.id,
mapping,
})
emit('switched')
close()
} finally {
isSubmitting.value = false
}
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.15s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -14,8 +14,9 @@
</span>
<div v-if="selectedCount > 0" class="ml-2 flex items-center gap-1">
<!-- Bulk status -->
<!-- Bulk status (scoped to single project's workflow) -->
<MalioSelect
v-if="!isMultiProject"
:model-value="null"
:options="statusOptions"
label="Status"
@@ -25,6 +26,13 @@
text-value="text-xs"
@update:model-value="(v: number | null) => v && emit('bulk-update', 'status', v)"
/>
<span
v-else
class="rounded border border-neutral-200 px-2 py-1 text-xs text-neutral-400"
title="Sélection multi-projets le statut dépend du workflow de chaque projet"
>
Status —
</span>
<!-- Bulk user -->
<MalioSelect
:model-value="null"
@@ -85,13 +93,15 @@
</template>
<script setup lang="ts">
import type { Task } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
const props = defineProps<{
const props = withDefaults(defineProps<{
selectedCount: number
totalCount: number
allSelected: boolean
@@ -101,7 +111,12 @@ const props = defineProps<{
priorities: TaskPriority[]
efforts: TaskEffort[]
groups: TaskGroup[]
}>()
selectedTasks?: Task[]
projects?: Project[]
}>(), {
selectedTasks: () => [],
projects: () => [],
})
const emit = defineEmits<{
(e: 'toggle-all'): void
@@ -110,23 +125,42 @@ const emit = defineEmits<{
(e: 'bulk-delete'): void
}>()
const statusOptions = computed(() =>
props.statuses.map(s => ({ label: s.label, value: s.id }))
)
const distinctProjectIds = computed(() => {
const ids = new Set<number>()
for (const t of props.selectedTasks) {
if (t.project) ids.add(t.project.id)
}
return ids
})
const isMultiProject = computed(() => distinctProjectIds.value.size > 1)
const statusOptions = computed<{ label: string, value: number }[]>(() => {
// Si on connait les projets et qu'on est sur un seul, on scope au workflow de ce projet
if (distinctProjectIds.value.size === 1 && props.projects.length > 0) {
const projectId = [...distinctProjectIds.value][0]
const project = props.projects.find(p => p.id === projectId)
if (project?.workflow?.statuses) {
return project.workflow.statuses.map(s => ({ label: s.label, value: s.id }))
}
}
// Fallback : statuts globaux fournis en props (ex. depuis projects/[id])
return props.statuses.map(s => ({ label: s.label, value: s.id }))
})
const userOptions = computed(() =>
props.users.map(u => ({ label: u.username, value: u.id }))
props.users.map(u => ({ label: u.username, value: u.id })),
)
const priorityOptions = computed(() =>
props.priorities.map(p => ({ label: p.label, value: p.id }))
props.priorities.map(p => ({ label: p.label, value: p.id })),
)
const effortOptions = computed(() =>
props.efforts.map(e => ({ label: e.label, value: e.id }))
props.efforts.map(e => ({ label: e.label, value: e.id })),
)
const groupOptions = computed(() =>
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id }))
props.groups.filter(g => !g.archived).map(g => ({ label: g.title, value: g.id })),
)
</script>

View File

@@ -40,6 +40,13 @@
</div>
<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"
:style="{ backgroundColor: task.status.color }"
>
{{ task.status.label }}
</span>
<span
v-if="task.priority"
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
@@ -106,8 +113,10 @@ import type { Task } from '~/services/dto/task'
const props = withDefaults(defineProps<{
task: Task
showProjectColor?: boolean
showStatusBadge?: boolean
}>(), {
showProjectColor: false,
showStatusBadge: false,
})
const emit = defineEmits<{

View File

@@ -60,16 +60,16 @@
<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 ['details', 'planning']"
v-for="tab in availableTabs"
: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'"
@click="activeTab = tab as 'details' | 'planning' | 'mails'"
>
{{ $t(`tasks.${tab}Tab`) }}
{{ tab === 'mails' ? $t('mail.taskTab.title') : $t(`tasks.${tab}Tab`) }}
</button>
</nav>
</div>
@@ -433,6 +433,76 @@
</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" />
</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>
<!-- Bouton lier un mail -->
<div class="pt-2">
<MalioButton
:label="$t('mail.taskTab.linkButton')"
variant="secondary"
icon-name="material-symbols:link"
icon-position="left"
:icon-size="14"
button-class="w-auto"
@click="showMailPickerModal = true"
/>
</div>
<!-- Modal picker mail -->
<MailPickerModal
v-if="task"
v-model="showMailPickerModal"
:task-id="task.id"
@linked="handleMailLinked"
/>
</div>
<!-- Footer -->
<div
class="mt-6 flex items-center border-t border-neutral-100 pt-5"
@@ -513,6 +583,8 @@ 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
@@ -545,7 +617,14 @@ function close() {
const isEditing = computed(() => !!props.task)
const isSubmitting = ref(false)
const confirmDeleteOpen = ref(false)
const activeTab = ref<'details' | 'planning'>('details')
const activeTab = ref<'details' | 'planning' | 'mails'>('details')
// ─── Onglet Mails ─────────────────────────────────────────────────────────
const mailService = useMailService()
const linkedMails = ref<MailMessageHeaderDto[]>([])
const mailsLoading = ref(false)
const showMailPickerModal = ref(false)
const giteaUrl = ref('')
const { getSettings: getGiteaSettings } = useGiteaService()
@@ -765,6 +844,7 @@ 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) {
@@ -823,6 +903,49 @@ 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()
}
})
async function handleMailLinked(): Promise<void> {
showMailPickerModal.value = false
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'

View File

@@ -1,122 +0,0 @@
<template>
<MalioDrawer v-model="isOpen" :title="isEditing ? $t('taskStatuses.editStatus') : $t('taskStatuses.addStatus')">
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.label"
label="Libellé"
input-class="w-full"
:error="touched.label && !form.label.trim() ? 'Le libellé est requis' : ''"
@blur="touched.label = true"
/>
<MalioInputText
v-model="form.position"
label="Position"
input-class="w-full"
type="number"
/>
<div class="mt-4">
<ColorPicker v-model="form.color" />
</div>
<div class="mt-4 flex items-center gap-2">
<input
id="isFinal"
v-model="form.isFinal"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
<label for="isFinal" class="text-sm font-medium text-neutral-700">
{{ $t('archive.statusFinal') }}
</label>
</div>
<div class="mt-6 flex justify-end">
<MalioButton
label="Enregistrer"
button-class="w-auto px-6"
:disabled="isSubmitting"
@click="handleSubmit"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { TaskStatus, TaskStatusWrite } from '~/services/dto/task-status'
import { useTaskStatusService } from '~/services/task-statuses'
const props = defineProps<{
modelValue: boolean
item: TaskStatus | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved'): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const isEditing = computed(() => !!props.item)
const isSubmitting = ref(false)
const form = reactive({
label: '',
position: '0',
color: '#222783',
isFinal: false,
})
const touched = reactive({
label: false,
})
watch(() => props.modelValue, (open) => {
if (open) {
if (props.item) {
form.label = props.item.label ?? ''
form.position = String(props.item.position ?? 0)
form.color = props.item.color ?? '#222783'
form.isFinal = props.item.isFinal ?? false
} else {
form.label = ''
form.position = '0'
form.color = '#222783'
form.isFinal = false
}
touched.label = false
}
})
const { create, update } = useTaskStatusService()
async function handleSubmit() {
touched.label = true
if (!form.label.trim()) return
isSubmitting.value = true
try {
const payload: TaskStatusWrite = {
label: form.label.trim(),
position: Number(form.position),
color: form.color,
isFinal: form.isFinal,
}
if (isEditing.value && props.item) {
await update(props.item.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>

View File

@@ -13,6 +13,14 @@
<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'"

View File

@@ -1,22 +1,45 @@
<template>
<div>
<p class="mb-2 text-sm font-medium text-neutral-700">Couleur</p>
<div class="flex flex-wrap gap-3">
<div class="flex flex-wrap items-center gap-3">
<button
v-for="color in colors"
v-for="color in presets"
:key="color"
type="button"
class="h-10 w-10 rounded-full border-2 transition-transform hover:scale-110"
:class="modelValue === color ? 'border-neutral-900 scale-110' : 'border-transparent'"
:class="isSelected(color) ? 'border-neutral-900 scale-110' : 'border-transparent'"
:style="{ backgroundColor: color }"
@click="emit('update:modelValue', color)"
:aria-label="`Choisir la couleur ${color}`"
@click="select(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">
defineProps<{
const props = defineProps<{
modelValue: string
}>()
@@ -24,8 +47,26 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const colors = [
'#222783', '#26A69A', '#E91E63', '#4A90D9',
'#7E57C2', '#8BC34A', '#FDD835', '#80DEEA', '#FF7043',
// 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 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>

View File

@@ -1,93 +0,0 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskStatuses.deleteStatus', { label: statusLabel }) }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ taskCount > 1 ? $t('taskStatuses.linkedTasksPlural', { count: taskCount }) : $t('taskStatuses.linkedTasks', { count: taskCount }) }}
</p>
<div class="mt-4">
<MalioSelect
v-model="targetStatusId"
:options="targetOptions"
:label="$t('taskStatuses.moveTo')"
:empty-option-label="$t('taskStatuses.backlog')"
min-width="w-full"
/>
</div>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
:disabled="isProcessing"
@click="confirm"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { TaskStatus } from '~/services/dto/task-status'
const props = defineProps<{
modelValue: boolean
statusLabel: string
taskCount: number
availableStatuses: TaskStatus[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', targetStatusId: number | null): void
}>()
const targetStatusId = ref<number | null>(null)
const isProcessing = ref(false)
const targetOptions = computed(() =>
props.availableStatuses.map(s => ({ label: s.label, value: s.id }))
)
watch(() => props.modelValue, (open) => {
if (open) {
targetStatusId.value = null
isProcessing.value = false
}
})
function cancel() {
emit('update:modelValue', false)
}
function confirm() {
isProcessing.value = true
emit('confirm', targetStatusId.value)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,75 @@
/**
* 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,
}
}

View File

@@ -0,0 +1,27 @@
# 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.

View File

@@ -0,0 +1,58 @@
# 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).

View File

@@ -0,0 +1,60 @@
# 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).

View File

@@ -0,0 +1,59 @@
# 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.

View File

@@ -0,0 +1,62 @@
# 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

View File

@@ -0,0 +1,43 @@
# 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

View File

@@ -0,0 +1,66 @@
# 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

View File

@@ -0,0 +1,66 @@
# 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)

View File

@@ -0,0 +1,97 @@
# 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.

View File

@@ -56,6 +56,37 @@
"moveTo": "Déplacer vers",
"backlog": "Backlog (sans statut)"
},
"workflows": {
"title": "Workflows",
"addWorkflow": "Ajouter un workflow",
"editWorkflow": "Modifier le workflow",
"name": "Nom",
"isDefault": "Workflow par défaut",
"statuses": "Statuts",
"addStatus": "Ajouter un statut",
"category": "Catégorie",
"created": "Workflow créé",
"updated": "Workflow mis à jour",
"deleted": "Workflow supprimé",
"switched": "Workflow du projet changé",
"switchTitle": "Changer de workflow",
"switchTargetLabel": "Nouveau workflow",
"switchMappingTitle": "Mapping des statuts",
"switchSourceCol": "Statut actuel",
"switchTargetCol": "Statut cible",
"switchTaskCountCol": "Tâches",
"switchToBacklog": "Mapper vers le backlog",
"switchConfirm": "Confirmer la migration",
"switchSummary": "{count} tâche(s) migrée(s), projet sur workflow « {name} »",
"deleteUsedBy": "Workflow utilisé par {count} projet(s) — impossible de supprimer.",
"categories": {
"todo": "À faire",
"in_progress": "En cours",
"blocked": "Bloqué",
"review": "En validation",
"done": "Terminé"
}
},
"taskEfforts": {
"created": "Effort créé avec succès.",
"updated": "Effort mis à jour avec succès.",
@@ -393,7 +424,21 @@
"title": "Mon profil",
"changeAvatar": "Changer l'avatar",
"removeAvatar": "Supprimer l'avatar",
"cropAvatar": "Recadrer l'avatar"
"cropAvatar": "Recadrer l'avatar",
"apiToken": {
"title": "Token API MCP",
"help": "Utilisé pour authentifier le serveur MCP HTTP (à coller dans le header Authorization: Bearer …). Ne pas partager.",
"label": "Token",
"empty": "Aucun token généré pour le moment.",
"generate": "Générer un token",
"regenerate": "Régénérer",
"copy": "Copier",
"copied": "Token copié dans le presse-papiers.",
"copyFailed": "Impossible de copier le token.",
"regenerated": "Nouveau token généré. L'ancien token est désormais invalide.",
"confirmTitle": "Régénérer le token MCP ?",
"confirmMessage": "L'ancien token sera immédiatement invalidé. Tous les clients MCP utilisant ce token devront être reconfigurés."
}
},
"bookstack": {
"settings": {
@@ -447,5 +492,127 @@
"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",
"priorityLabel": "Priorité (optionnelle)",
"priorityPlaceholder": "Aucune priorité",
"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…"
},
"pickerModal": {
"title": "Lier un mail à cette tâche",
"searchPlaceholder": "Rechercher un mail (sujet, expéditeur)…",
"empty": "Aucun mail correspondant.",
"loading": "Chargement des mails…",
"submit": "Lier ce mail"
},
"taskTab": {
"title": "Mails",
"empty": "Aucun mail lié à cette tâche.",
"linkButton": "Lier un mail",
"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é."
}
}

View File

@@ -53,6 +53,23 @@
: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"
@@ -162,9 +179,18 @@ 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
@@ -207,6 +233,17 @@ 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)

View File

@@ -15,6 +15,7 @@
"@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",
@@ -23,6 +24,9 @@
"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": {
@@ -5859,6 +5863,16 @@
"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",
@@ -5905,6 +5919,13 @@
"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",
@@ -8100,6 +8121,15 @@
"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",
@@ -12223,7 +12253,6 @@
"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"
}
@@ -12244,7 +12273,6 @@
"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",
@@ -12278,7 +12306,6 @@
"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",

View File

@@ -19,6 +19,7 @@
"@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",
@@ -27,5 +28,8 @@
"vue-advanced-cropper": "^2.8.9",
"vue-chartjs": "^5.3.3",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/dompurify": "^3.0.5"
}
}

View File

@@ -22,7 +22,7 @@
<div>
<AdminClientTab v-if="activeTab === 'clients'" />
<AdminStatusTab v-if="activeTab === 'statuses'" />
<AdminWorkflowTab v-if="activeTab === 'workflows'" />
<AdminEffortTab v-if="activeTab === 'efforts'" />
<AdminPriorityTab v-if="activeTab === 'priorities'" />
<AdminTagTab v-if="activeTab === 'tags'" />
@@ -30,6 +30,7 @@
<AdminGiteaTab v-if="activeTab === 'gitea'" />
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
<AdminZimbraTab v-if="activeTab === 'zimbra'" />
<AdminMailTab v-if="activeTab === 'mail'" />
</div>
</div>
</template>
@@ -40,7 +41,7 @@ useHead({ title: 'Administration' })
const tabs = [
{ key: 'clients', label: 'Clients' },
{ key: 'statuses', label: 'Statuts' },
{ key: 'workflows', label: 'Workflows' },
{ key: 'efforts', label: 'Efforts' },
{ key: 'priorities', label: 'Priorités' },
{ key: 'tags', label: 'Tags' },
@@ -48,6 +49,7 @@ 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']

168
frontend/pages/help.vue Normal file
View File

@@ -0,0 +1,168 @@
<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>

182
frontend/pages/mail.vue Normal file
View File

@@ -0,0 +1,182 @@
<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>

View File

@@ -7,6 +7,8 @@ import type { TaskTag } from '~/services/dto/task-tag'
import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import type { Project } from '~/services/dto/project'
import type { StatusCategory } 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'
@@ -60,6 +62,7 @@ const viewMode = ref<'kanban' | 'list'>('kanban')
// Bulk selection
const selectedTaskIds = reactive(new Set<number>())
const selectedTasksArray = computed(() => tasks.value.filter(t => selectedTaskIds.has(t.id)))
// Modal
const taskModalOpen = ref(false)
@@ -112,13 +115,11 @@ const sortOptions = computed(() => [
{ label: t('myTasks.sortScheduledStart'), value: SORT_SCHEDULED },
])
// Kanban helpers
const sortedStatuses = computed(() =>
[...statuses.value].sort((a, b) => a.position - b.position)
)
// Kanban helpers (grouped by canonical status category)
const CATEGORIES: StatusCategory[] = ['todo', 'in_progress', 'blocked', 'review', 'done']
function tasksByStatus(statusId: number): Task[] {
return tasks.value.filter(t => t.status?.id === statusId)
function tasksByCategory(category: StatusCategory): Task[] {
return tasks.value.filter(t => t.status?.category === category)
}
const backlogTasks = computed(() =>
@@ -205,44 +206,6 @@ watch(selectedProjectId, () => {
selectedGroupId.value = null
}, { flush: 'sync' })
// Drag & drop
const dragOverStatusId = ref<number | null>(null)
const dragCounter = ref(0)
function onDragEnter(id: number) {
dragCounter.value++
dragOverStatusId.value = id
}
function onDragLeave() {
dragCounter.value--
if (dragCounter.value === 0) {
dragOverStatusId.value = null
}
}
function onDrop(event: DragEvent) {
dragCounter.value = 0
dragOverStatusId.value = null
return Number(event.dataTransfer!.getData('text/plain'))
}
async function onDropStatus(event: DragEvent, status: TaskStatus) {
const taskId = onDrop(event)
const task = tasks.value.find(t => t.id === taskId)
if (!task || task.status?.id === status.id) return
task.status = status
await taskService.update(taskId, { status: `/api/task_statuses/${status.id}` })
}
async function onDropBacklog(event: DragEvent) {
const taskId = onDrop(event)
const task = tasks.value.find(t => t.id === taskId)
if (!task || !task.status) return
task.status = null
await taskService.update(taskId, { status: null })
}
// Modal
function openTaskCreate() {
selectedTask.value = null
@@ -428,36 +391,29 @@ onMounted(async () => {
</div>
</div>
<!-- Kanban View -->
<!-- Kanban View grouped by canonical category -->
<div v-if="viewMode === 'kanban'">
<div class="mt-6 flex h-[calc(100vh-260px)] gap-3 overflow-x-auto pb-4">
<div
v-for="status in sortedStatuses"
:key="status.id"
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent
@dragenter.prevent="onDragEnter(status.id)"
@dragleave="onDragLeave"
@drop.prevent="onDropStatus($event, status)"
v-for="cat in CATEGORIES"
:key="cat"
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 text-white"
:style="{ backgroundColor: status.color }"
>
{{ status.label }} ({{ tasksByStatus(status.id).length }})
<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">
<div class="flex flex-col gap-3">
<TaskCard
v-for="task in tasksByStatus(status.id)"
v-for="task in tasksByCategory(cat)"
:key="task.id"
:task="task"
show-project-color
show-status-badge
@click="openTaskEdit(task)"
/>
<p
v-if="tasksByStatus(status.id).length === 0"
v-if="tasksByCategory(cat).length === 0"
class="py-4 text-center text-xs text-neutral-400"
>
{{ $t('myTasks.noTasks') }}
@@ -467,15 +423,8 @@ onMounted(async () => {
</div>
</div>
<!-- Backlog below kanban -->
<div
class="mt-8 rounded-lg p-4 transition-colors"
:class="dragOverStatusId === 0 ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent
@dragenter.prevent="onDragEnter(0)"
@dragleave="onDragLeave"
@drop.prevent="onDropBacklog($event)"
>
<!-- Backlog below kanban (no drag/drop status change goes through TaskModal) -->
<div class="mt-8 rounded-lg bg-neutral-50 p-4">
<h2 class="text-lg font-bold text-neutral-900">{{ $t('myTasks.backlog') }} ({{ backlogTasks.length }})</h2>
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<TaskCard
@@ -483,6 +432,7 @@ onMounted(async () => {
:key="task.id"
:task="task"
show-project-color
show-status-badge
@click="openTaskEdit(task)"
/>
</div>
@@ -507,6 +457,8 @@ onMounted(async () => {
:priorities="priorities"
:efforts="efforts"
:groups="groups"
:selected-tasks="selectedTasksArray"
:projects="projects"
@toggle-all="toggleSelectAll(tasks)"
@bulk-update="onBulkUpdate"
@bulk-archive="onBulkArchive"

View File

@@ -37,6 +37,56 @@
</div>
</div>
<!-- API Token MCP (interne uniquement) -->
<div
v-if="!isClientOnly"
class="mt-8 rounded-xl border border-neutral-200 bg-white p-6 shadow-sm"
>
<h2 class="mb-1 text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.title') }}</h2>
<p class="mb-4 text-sm text-neutral-600">{{ $t('profile.apiToken.help') }}</p>
<div v-if="auth.user?.apiToken">
<MalioInputPassword
:model-value="auth.user.apiToken"
:label="$t('profile.apiToken.label')"
readonly
@update:model-value="() => {}"
/>
<div class="mt-3 flex flex-wrap gap-3">
<MalioButton
variant="secondary"
button-class="w-auto px-4"
icon-name="mdi:content-copy"
icon-position="left"
:label="$t('profile.apiToken.copy')"
@click="onCopy"
/>
<MalioButton
variant="danger"
button-class="w-auto px-4"
icon-name="mdi:refresh"
icon-position="left"
:disabled="regenerating"
:label="$t('profile.apiToken.regenerate')"
@click="showConfirm = true"
/>
</div>
</div>
<div v-else>
<p class="mb-4 text-sm text-neutral-500 italic">{{ $t('profile.apiToken.empty') }}</p>
<MalioButton
variant="primary"
button-class="w-auto px-4"
icon-name="mdi:key-plus"
icon-position="left"
:disabled="regenerating"
:label="$t('profile.apiToken.generate')"
@click="onRegenerate"
/>
</div>
</div>
<!-- Crop modal -->
<AvatarCropper
v-if="selectedFile"
@@ -44,14 +94,45 @@
@crop="onCrop"
@cancel="selectedFile = null"
/>
<!-- Confirm regenerate modal -->
<Teleport v-if="showConfirm" to="body">
<div class="fixed inset-0 z-[70] flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click.stop="showConfirm = false" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.confirmTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('profile.apiToken.confirmMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
button-class="w-auto px-4"
:label="$t('common.cancel')"
@click="showConfirm = false"
/>
<MalioButton
variant="danger"
button-class="w-auto px-4"
:disabled="regenerating"
:label="$t('profile.apiToken.regenerate')"
@click="onRegenerate"
/>
</div>
</div>
</div>
</Teleport>
</div>
</NuxtLayout>
</template>
<script setup lang="ts">
import { useAvatarService } from '~/composables/useAvatarService'
import { useApiTokenService } from '~/services/api-token'
const auth = useAuthStore()
const toast = useToast()
const { t } = useI18n()
const isClientOnly = computed(() =>
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
@@ -61,9 +142,12 @@ definePageMeta({
layout: false,
})
const { upload, remove } = useAvatarService()
const { regenerate } = useApiTokenService()
const selectedFile = ref<File | null>(null)
const removing = ref(false)
const regenerating = ref(false)
const showConfirm = ref(false)
function onFileSelect(event: Event) {
const input = event.target as HTMLInputElement
@@ -97,4 +181,28 @@ async function onRemove() {
removing.value = false
}
}
async function onCopy() {
if (!auth.user?.apiToken) return
try {
await navigator.clipboard.writeText(auth.user.apiToken)
toast.success({ message: t('profile.apiToken.copied') })
} catch {
toast.error({ message: t('profile.apiToken.copyFailed') })
}
}
async function onRegenerate() {
regenerating.value = true
try {
const newToken = await regenerate()
if (auth.user) {
auth.user.apiToken = newToken
}
showConfirm.value = false
toast.success({ message: t('profile.apiToken.regenerated') })
} finally {
regenerating.value = false
}
}
</script>

View File

@@ -82,7 +82,6 @@ import type { TaskGroup } from '~/services/dto/task-group'
import type { UserData } from '~/services/dto/user-data'
import { useProjectService } from '~/services/projects'
import { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskEffortService } from '~/services/task-efforts'
import { useTaskPriorityService } from '~/services/task-priorities'
import { useTaskTagService } from '~/services/task-tags'
@@ -96,7 +95,6 @@ useHead({ title: 'Archives' })
const projectService = useProjectService()
const taskService = useTaskService()
const statusService = useTaskStatusService()
const effortService = useTaskEffortService()
const priorityService = useTaskPriorityService()
const tagService = useTaskTagService()
@@ -105,8 +103,11 @@ const userService = useUserService()
const project = ref<Project | null>(null)
const archivedTasks = ref<Task[]>([])
const statuses = ref<TaskStatus[]>([])
const efforts = ref<TaskEffort[]>([])
const statuses = computed<TaskStatus[]>(() =>
[...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position),
)
const priorities = ref<TaskPriority[]>([])
const tags = ref<TaskTag[]>([])
const groups = ref<TaskGroup[]>([])
@@ -126,10 +127,9 @@ const filteredTasks = computed(() => {
})
async function loadData() {
const [p, t, s, e, pr, ty, g, u] = await Promise.all([
const [p, t, e, pr, ty, g, u] = await Promise.all([
projectService.getById(projectId.value),
taskService.getByProject(projectId.value, true),
statusService.getAll(),
effortService.getAll(),
priorityService.getAll(),
tagService.getAll(),
@@ -138,7 +138,6 @@ async function loadData() {
])
project.value = p
archivedTasks.value = t
statuses.value = s
efforts.value = e
priorities.value = pr
tags.value = ty

View File

@@ -218,7 +218,6 @@ import type { Client } from '~/services/dto/client'
import { useProjectService } from '~/services/projects'
import { useClientService } from '~/services/clients'
import { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskEffortService } from '~/services/task-efforts'
import { useTaskPriorityService } from '~/services/task-priorities'
import { useTaskTagService } from '~/services/task-tags'
@@ -234,7 +233,6 @@ useHead({ title: 'Projet' })
const projectService = useProjectService()
const clientService = useClientService()
const taskService = useTaskService()
const statusService = useTaskStatusService()
const effortService = useTaskEffortService()
const priorityService = useTaskPriorityService()
const tagService = useTaskTagService()
@@ -243,7 +241,6 @@ const userService = useUserService()
const project = ref<Project | null>(null)
const tasks = ref<Task[]>([])
const statuses = ref<TaskStatus[]>([])
const efforts = ref<TaskEffort[]>([])
const priorities = ref<TaskPriority[]>([])
const tags = ref<TaskTag[]>([])
@@ -252,6 +249,10 @@ const users = ref<UserData[]>([])
const clients = ref<Client[]>([])
const isLoading = ref(true)
const statuses = computed<TaskStatus[]>(() =>
[...(project.value?.workflow?.statuses ?? [])].sort((a, b) => a.position - b.position),
)
const selectedGroupId = ref<number | null>(null)
const selectedTagId = ref<number | null>(null)
const selectedAssigneeId = ref<number | null>(null)
@@ -333,10 +334,9 @@ const backlogTasks = computed(() =>
async function loadData() {
isLoading.value = true
try {
const [p, t, s, e, pr, ty, g, u, c] = await Promise.all([
const [p, t, e, pr, ty, g, u, c] = await Promise.all([
projectService.getById(projectId.value),
taskService.getByProject(projectId.value),
statusService.getAll(),
effortService.getAll(),
priorityService.getAll(),
tagService.getAll(),
@@ -346,7 +346,6 @@ async function loadData() {
])
project.value = p
tasks.value = t
statuses.value = s
efforts.value = e
priorities.value = pr
tags.value = ty

View File

@@ -0,0 +1,12 @@
export function useApiTokenService() {
const api = useApi()
async function regenerate(): Promise<string> {
const data = await api.post<{ apiToken: string }>('/me/regenerate-api-token', {}, {
toast: false,
})
return data.apiToken
}
return { regenerate }
}

View File

@@ -0,0 +1,121 @@
// 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
priority?: string | null
}
// Input : lier une tâche existante à un mail
export type MailLinkTaskInput = {
taskId: number
}
// Résultat de la sync manuelle
export type MailSyncResultDto = {
dispatched: boolean
}

View File

@@ -1,4 +1,5 @@
import type { Client } from './client'
import type { Workflow } from './workflow'
export type Project = {
id: number
@@ -8,6 +9,7 @@ export type Project = {
description: string | null
color: string
client: Client | null
workflow: Workflow
giteaOwner: string | null
giteaRepo: string | null
bookstackShelfId: number | null
@@ -22,6 +24,7 @@ export type ProjectWrite = {
description: string | null
color: string
client: string | null // IRI : "/api/clients/1" ou null
workflow?: string // IRI : "/api/workflows/1"
giteaOwner?: string | null
giteaRepo?: string | null
bookstackShelfId?: number | null

View File

@@ -1,3 +1,5 @@
import type { StatusCategory } from './workflow'
export type TaskStatus = {
id: number
'@id'?: string
@@ -5,6 +7,8 @@ export type TaskStatus = {
color: string
position: number
isFinal: boolean
category: StatusCategory
workflow?: { '@id': string, id: number } | string
}
export type TaskStatusWrite = {
@@ -12,4 +16,6 @@ export type TaskStatusWrite = {
color: string
position: number
isFinal: boolean
category: StatusCategory
workflow?: string
}

View File

@@ -8,6 +8,7 @@ export type UserData = {
client?: { id: number; name: string } | null
allowedProjects?: Project[]
avatarUrl?: string | null
apiToken?: string | null
}
export type UserWrite = {

View File

@@ -0,0 +1,27 @@
import type { TaskStatus, TaskStatusWrite } from './task-status'
export type StatusCategory = 'todo' | 'in_progress' | 'blocked' | 'review' | 'done'
export const STATUS_CATEGORY_LABEL: Record<StatusCategory, string> = {
todo: 'À faire',
in_progress: 'En cours',
blocked: 'Bloqué',
review: 'En validation',
done: 'Terminé',
}
export type Workflow = {
id: number
'@id'?: string
name: string
isDefault: boolean
position: number
statuses: TaskStatus[]
}
export type WorkflowWrite = {
name: string
isDefault: boolean
position: number
statuses?: TaskStatusWrite[]
}

276
frontend/services/mail.ts Normal file
View File

@@ -0,0 +1,276 @@
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,
}
}

View File

@@ -0,0 +1,55 @@
import type { Workflow, WorkflowWrite } from './dto/workflow'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
type SwitchPayload = {
workflowId: number
mapping: Record<string, number | null>
}
type SwitchResult = {
projectId: number
workflowId: number
migratedTaskCount: number
}
export function useWorkflowService() {
const api = useApi()
async function getAll(): Promise<Workflow[]> {
const data = await api.get<HydraCollection<Workflow>>('/workflows')
return extractHydraMembers(data)
}
async function getOne(id: number): Promise<Workflow> {
return api.get<Workflow>(`/workflows/${id}`)
}
async function create(payload: WorkflowWrite): Promise<Workflow> {
return api.post<Workflow>('/workflows', payload as Record<string, unknown>, {
toastSuccessKey: 'workflows.created',
})
}
async function update(id: number, payload: Partial<WorkflowWrite>): Promise<Workflow> {
return api.patch<Workflow>(`/workflows/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'workflows.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/workflows/${id}`, {}, {
toastSuccessKey: 'workflows.deleted',
})
}
async function switchOnProject(projectId: number, payload: SwitchPayload): Promise<SwitchResult> {
return api.post<SwitchResult>(
`/projects/${projectId}/switch-workflow`,
payload as unknown as Record<string, unknown>,
{ toastSuccessKey: 'workflows.switched' },
)
}
return { getAll, getOne, create, update, remove, switchOnProject }
}

332
frontend/stores/mail.ts Normal file
View File

@@ -0,0 +1,332 @@
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,
}
})

View File

@@ -0,0 +1,160 @@
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')
}

View File

@@ -122,5 +122,11 @@ 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

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260519175041 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create workflow table and seed default Standard workflow';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE workflow (
id SERIAL NOT NULL,
name VARCHAR(255) NOT NULL,
is_default BOOLEAN DEFAULT FALSE NOT NULL,
position INT DEFAULT 0 NOT NULL,
PRIMARY KEY (id)
)');
$this->addSql('CREATE UNIQUE INDEX uniq_workflow_name ON workflow (name)');
$this->addSql('CREATE UNIQUE INDEX uniq_workflow_one_default ON workflow (is_default) WHERE is_default = TRUE');
$this->addSql("INSERT INTO workflow (name, is_default, position) VALUES ('Standard', TRUE, 0)");
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE workflow');
}
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Doctrine\Migrations\Exception\MigrationException;
final class Version20260519175114 extends AbstractMigration
{
public function getDescription(): string
{
return 'Attach existing TaskStatus rows to Standard workflow and backfill category (strict mapping)';
}
public function up(Schema $schema): void
{
// 1) Récupérer l'id du workflow Standard
$standardId = $this->connection->fetchOne("SELECT id FROM workflow WHERE name = 'Standard'");
if (!$standardId) {
throw new MigrationException('Workflow Standard introuvable. Lancer M1 d\'abord.');
}
// 2) Garde-fou : vérifier qu'il n'y a pas de label hors mapping
$mapping = [
'A faire' => 'todo',
'À faire' => 'todo',
'En cours' => 'in_progress',
'Bloqué' => 'blocked',
'En attente de validation' => 'review',
'Terminé' => 'done',
];
$rows = $this->connection->fetchAllAssociative('SELECT id, label FROM task_status');
foreach ($rows as $row) {
if (!isset($mapping[$row['label']])) {
throw new MigrationException(sprintf(
'TaskStatus #%d ("%s") n\'est pas mappable. Ajoutez son mapping dans la migration avant de relancer.',
$row['id'],
$row['label'],
));
}
}
// 3) Ajouter colonnes nullable
$this->addSql('ALTER TABLE task_status ADD COLUMN workflow_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE task_status ADD COLUMN category VARCHAR(32) DEFAULT NULL');
// 4) Backfill
$this->addSql("UPDATE task_status SET workflow_id = {$standardId}");
foreach ($mapping as $label => $cat) {
$this->addSql(sprintf(
"UPDATE task_status SET category = '%s' WHERE label = '%s'",
$cat,
str_replace("'", "''", $label),
));
}
// 5) NOT NULL + FK
$this->addSql('ALTER TABLE task_status ALTER COLUMN workflow_id SET NOT NULL');
$this->addSql('ALTER TABLE task_status ALTER COLUMN category SET NOT NULL');
$this->addSql('ALTER TABLE task_status ADD CONSTRAINT FK_task_status_workflow FOREIGN KEY (workflow_id) REFERENCES workflow (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_task_status_workflow ON task_status (workflow_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE task_status DROP CONSTRAINT FK_task_status_workflow');
$this->addSql('DROP INDEX IDX_task_status_workflow');
$this->addSql('ALTER TABLE task_status DROP COLUMN workflow_id');
$this->addSql('ALTER TABLE task_status DROP COLUMN category');
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Doctrine\Migrations\Exception\MigrationException;
final class Version20260519175142 extends AbstractMigration
{
public function getDescription(): string
{
return 'Attach existing projects to Standard workflow (NOT NULL, RESTRICT)';
}
public function up(Schema $schema): void
{
$standardId = $this->connection->fetchOne("SELECT id FROM workflow WHERE name = 'Standard'");
if (!$standardId) {
throw new MigrationException('Workflow Standard introuvable.');
}
$this->addSql('ALTER TABLE project ADD COLUMN workflow_id INT DEFAULT NULL');
$this->addSql("UPDATE project SET workflow_id = {$standardId}");
$this->addSql('ALTER TABLE project ALTER COLUMN workflow_id SET NOT NULL');
$this->addSql('ALTER TABLE project ADD CONSTRAINT FK_project_workflow FOREIGN KEY (workflow_id) REFERENCES workflow (id) ON DELETE RESTRICT NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_project_workflow ON project (workflow_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE project DROP CONSTRAINT FK_project_workflow');
$this->addSql('DROP INDEX IDX_project_workflow');
$this->addSql('ALTER TABLE project DROP COLUMN workflow_id');
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260519175338 extends AbstractMigration
{
public function getDescription(): string
{
return 'Align workflow schema with Doctrine naming (indexes, IDENTITY). Drops the partial unique index uniq_workflow_one_default (uniqueness is enforced by UniqueDefaultWorkflowListener at app level).';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER INDEX idx_project_workflow RENAME TO IDX_2FB3D0EE2C7C2CBA');
$this->addSql('ALTER INDEX idx_task_status_workflow RENAME TO IDX_40A9E1CF2C7C2CBA');
$this->addSql('DROP INDEX uniq_workflow_one_default');
$this->addSql('ALTER TABLE workflow ALTER id DROP DEFAULT');
$this->addSql('ALTER TABLE workflow ALTER id ADD GENERATED BY DEFAULT AS IDENTITY');
// Aligner la séquence d'identity sur MAX(id) pour éviter le conflit avec les rows déjà insérés par M1
$this->addSql('SELECT setval(pg_get_serial_sequence(\'workflow\', \'id\'), COALESCE((SELECT MAX(id) FROM workflow), 0) + 1, false)');
$this->addSql('ALTER INDEX uniq_workflow_name RENAME TO UNIQ_65C598165E237E06');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER INDEX idx_2fb3d0ee2c7c2cba RENAME TO idx_project_workflow');
$this->addSql('ALTER INDEX idx_40a9e1cf2c7c2cba RENAME TO idx_task_status_workflow');
$this->addSql('ALTER TABLE workflow ALTER id DROP IDENTITY');
$this->addSql("ALTER TABLE workflow ALTER id SET DEFAULT nextval('workflow_id_seq'::regclass)");
$this->addSql('CREATE UNIQUE INDEX uniq_workflow_one_default ON workflow (is_default) WHERE (is_default = true)');
$this->addSql('ALTER INDEX uniq_65c598165e237e06 RENAME TO uniq_workflow_name');
}
}

View File

@@ -0,0 +1,115 @@
<?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');
}
}

View File

@@ -0,0 +1,56 @@
<?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()');
}
}

View File

@@ -0,0 +1,31 @@
<?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)');
}
}

View File

@@ -15,6 +15,19 @@
<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>

View File

@@ -0,0 +1,69 @@
<?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;
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use Symfony\Component\Serializer\Attribute\Groups;
final class SwitchWorkflowOutput
{
#[Groups(['switch_workflow:read'])]
public int $projectId;
#[Groups(['switch_workflow:read'])]
public int $workflowId;
#[Groups(['switch_workflow:read'])]
public int $migratedTaskCount;
public function __construct(int $projectId, int $workflowId, int $migratedTaskCount)
{
$this->projectId = $projectId;
$this->workflowId = $workflowId;
$this->migratedTaskCount = $migratedTaskCount;
}
}

View File

@@ -0,0 +1,110 @@
<?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;
}
}

View File

@@ -0,0 +1,93 @@
<?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;
}
}

View File

@@ -0,0 +1,105 @@
<?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\TaskPriority;
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['priorityId']) && null !== $body['priorityId']) {
$priority = $this->em->getRepository(TaskPriority::class)->find($body['priorityId']);
if (null !== $priority) {
$task->setPriority($priority);
}
}
$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);
}
}

View File

@@ -0,0 +1,42 @@
<?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);
}
}

View File

@@ -0,0 +1,69 @@
<?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);
}
}

View File

@@ -0,0 +1,87 @@
<?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,
]);
}
}

View File

@@ -0,0 +1,53 @@
<?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()]);
}
}

View File

@@ -0,0 +1,53 @@
<?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()]);
}
}

View File

@@ -0,0 +1,65 @@
<?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'],
]);
}
}

View File

@@ -0,0 +1,40 @@
<?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
);
}
}

View File

@@ -0,0 +1,46 @@
<?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(),
]);
}
}
}

View File

@@ -0,0 +1,54 @@
<?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);
}
}

View File

@@ -0,0 +1,54 @@
<?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);
}
}

Some files were not shown because too many files have changed in this diff Show More