Compare commits

...

47 Commits

Author SHA1 Message Date
gitea-actions
4216f1b5a1 chore: bump version to v0.1.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
2026-03-15 18:07:23 +00:00
c72f17eb93 docs : add time tracking design spec
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:59:02 +01:00
4c19b68156 fix(gitea) : propagate API errors instead of silently returning empty results
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:58:55 +01:00
63e4af785e chore : update auto-generated reference config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:58:47 +01:00
f5e41bc377 docs : add client portal design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:54:49 +01:00
f978df6a4b fix(frontend) : explicit import for ConfirmDeleteDocumentModal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:53:39 +01:00
98e832afa5 fix(frontend) : use dedicated confirm modal component for document deletion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:48:10 +01:00
cbfbb16c59 feat(frontend) : replace confirm() with themed modal for document deletion
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:33:44 +01:00
354d994766 fix : tag TaskDocumentListener as doctrine entity listener
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:32:02 +01:00
06771c17e0 fix(bookstack) : add uriVariables to BookStackLink and BookStackSearchResult
API Platform 4 requires explicit uriVariables declaration for
URI template parameters on DTO resources.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:27:57 +01:00
9908f34580 fix(frontend) : refresh documents locally after upload/delete and improve progress UX
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:21:04 +01:00
7bf632c1da feat(bookstack) : integrate TaskBookStackLinks into TaskModal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:39 +01:00
66a75c6b6a feat(bookstack) : add TaskBookStackLinks component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:37 +01:00
f53b2f3d1f feat(bookstack) : add shelf select to ProjectDrawer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:34 +01:00
c9a3c7c5f8 feat(bookstack) : add BookStack tab to admin page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:30 +01:00
5777e8386f feat(bookstack) : add AdminBookStackTab component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:27 +01:00
06f2a9e1ea feat(bookstack) : add i18n translations for BookStack
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:24 +01:00
b5fa9e7d06 feat(bookstack) : add frontend BookStack service
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:20 +01:00
73ecbbc95b feat(bookstack) : add frontend BookStack DTOs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:15:12 +01:00
5327155a80 fix(frontend) : add missing useTaskDocumentService imports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:12:29 +01:00
9e638c32b8 feat(bookstack) : add BookStackSearchResult API resource for shelf-scoped search
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:10:47 +01:00
bc331982d5 feat(bookstack) : add BookStackLink API resource with CRUD operations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:10:24 +01:00
1e311242a9 feat(bookstack) : add BookStackShelf API resource for listing shelves
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:09:51 +01:00
97c6ef6a52 feat(bookstack) : add BookStackTestConnection API resource
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:09:36 +01:00
245a8a932e feat(frontend) : integrate documents into TaskModal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:09:20 +01:00
28fbc73248 feat(bookstack) : add BookStackSettings API resource with provider and processor
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:09:20 +01:00
df00b27a64 feat(bookstack) : add BookStackApiService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:08:51 +01:00
ee38f99022 feat(bookstack) : add BookStackApiException
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:08:15 +01:00
48ef434f8b feat(frontend) : add document upload, list and preview components
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:08:10 +01:00
e53862d71f feat(frontend) : add TaskDocument DTO, service and i18n translations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:07:00 +01:00
52063cb4fa feat(bookstack) : add migration for BookStack tables and Project columns
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:06:14 +01:00
06832c24e1 feat : add document upload processor, download controller and cleanup listener
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:05:58 +01:00
8fbafc1f8a feat(bookstack) : add bookstackShelfId and bookstackShelfName to Project 2026-03-15 18:05:13 +01:00
585cc3368f feat(bookstack) : add TaskBookStackLink entity and repository 2026-03-15 18:05:09 +01:00
043826075d feat(bookstack) : add BookStackConfiguration entity and repository 2026-03-15 18:05:07 +01:00
8ec98a593a feat : add task_document migration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:04:14 +01:00
3dd2d39222 refactor : rename GITEA_ENCRYPTION_KEY to ENCRYPTION_KEY
Generic encryption key name for shared use across Gitea and BookStack
token encryption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:03:52 +01:00
cfaa6c42ec feat : add TaskDocument entity with Task relation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:03:20 +01:00
a36cd92a7f feat(config) : set upload limits to 50MB and add uploads volume
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:02:09 +01:00
bfffbe7041 docs : add BookStack connector implementation plan
21-task plan covering backend (entities, migration, service, API
resources) and frontend (DTOs, service, admin tab, project drawer,
task modal integration). Reviewed and fixed: readonly class issue,
page URL construction, Delete provider handling, task:read group,
search query syntax, security attributes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 18:00:34 +01:00
c9993ef32d docs : add task documents implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 17:59:29 +01:00
efc3742fff docs : update task documents spec after review
Address review findings: add EntityListener for file cleanup on
cascade delete, dedicated download endpoint, sequential upload,
i18n keys, .gitignore entry, and error handling strategy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 08:49:18 +01:00
e047b98bed docs : update BookStack spec with review fixes
Address critical and important review findings: search-in-shelf
algorithm detail, unique constraint, TokenEncryptor refactoring,
pagination specifics, and technical notes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 08:48:10 +01:00
758c9f6fbd docs : add task documents upload design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 08:46:10 +01:00
2c93e83e6b docs : add BookStack connector design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 08:45:30 +01:00
25b648a1b1 fix(frontend) : align time-tracking filters with view mode toggle
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 08:34:07 +01:00
445f51b473 fix(gitea) : fetch only branch-specific commits using compare API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 08:16:55 +01:00
62 changed files with 7335 additions and 59 deletions

2
.env
View File

@@ -20,4 +20,4 @@ JWT_COOKIE_TTL=86400
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8" DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
GITEA_ENCRYPTION_KEY= ENCRYPTION_KEY=aaaaaaaaa

View File

@@ -12,22 +12,23 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4.
## Structure ## Structure
``` ```
src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry) src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration)
src/ApiResource/ # Ressources API Platform (si découplées des entités) src/ApiResource/ # Ressources API Platform (si découplées des entités)
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor) src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, Gitea*Provider, Gitea*Processor)
src/Repository/ # Repositories Doctrine src/Repository/ # Repositories Doctrine
src/DataFixtures/ # Fixtures src/DataFixtures/ # Fixtures
config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine) config/ # Config Symfony (security, api_platform, lexik_jwt, nelmio_cors, doctrine)
config/jwt/ # Clés JWT (private.pem, public.pem) config/jwt/ # Clés JWT (private.pem, public.pem)
migrations/ # Migrations Doctrine migrations/ # Migrations Doctrine
docs/plans/ # Plans d'implémentation docs/plans/ # Plans d'implémentation
docs/superpowers/ # Plans et specs superpowers
frontend/ # App Nuxt 4 frontend/ # App Nuxt 4
frontend/pages/ # Pages (index, login, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin) frontend/pages/ # Pages (index, login, my-tasks, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin)
frontend/layouts/ # Layouts (pas "layout") frontend/layouts/ # Layouts (pas "layout")
frontend/components/ # Composants Vue (AppTopNav, AppDrawer, ColorPicker, DataTable, ClientDrawer, ProjectDrawer, ProjectGroupTab, TaskCard, TaskDrawer, TaskModal, TaskEffortDrawer, TaskGroupDrawer, TaskPriorityDrawer, TaskStatusDrawer, TaskTagDrawer, Admin*Tab, SidebarLink, SidebarTimer, TimeEntryBlock, TimeEntryContextMenu, TimeEntryDrawer, TimeEntryList, TimeTrackingCalendar, UserDrawer, ConfirmDeleteStatusModal, ConfirmDeleteTaskModal) frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/)
frontend/composables/# Composables (useApi, useAppVersion) frontend/composables/# Composables (useApi, useAppVersion)
frontend/stores/ # Stores Pinia (auth, ui, timer) frontend/stores/ # Stores Pinia (auth, ui, timer)
frontend/services/ # Services API (auth, clients, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries) frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries)
frontend/services/dto/ # Types TypeScript frontend/services/dto/ # Types TypeScript
frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/) frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/)
``` ```

View File

@@ -7,6 +7,7 @@
# Put parameters here that don't need to change on each machine where the app is deployed # Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents'
imports: imports:
- { resource: version.yaml } - { resource: version.yaml }
@@ -24,3 +25,17 @@ services:
# add more service definitions when explicit configuration is needed # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones
App\EventListener\TaskDocumentListener:
arguments:
$uploadDir: '%task_document_upload_dir%'
tags:
- { name: doctrine.orm.entity_listener }
App\State\TaskDocumentProcessor:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Controller\TaskDocumentDownloadController:
arguments:
$uploadDir: '%task_document_upload_dir%'

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.0' app.version: '0.1.1'

View File

@@ -24,6 +24,7 @@ services:
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini - ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini - ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
- ./LOG:/var/www/html/LOG - ./LOG:/var/www/html/LOG
- uploads_data:/var/www/html/var/uploads
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
depends_on: depends_on:
@@ -56,3 +57,4 @@ services:
restart: unless-stopped restart: unless-stopped
volumes: volumes:
pg_data: pg_data:
uploads_data:

View File

@@ -5,6 +5,8 @@ server {
root /var/www/html/frontend/dist; root /var/www/html/frontend/dist;
index index.html; index index.html;
client_max_body_size 55m;
location ^~ /api/ { location ^~ /api/ {
root /var/www/html/public; root /var/www/html/public;
try_files $uri /index.php?$query_string; try_files $uri /index.php?$query_string;

View File

@@ -1,4 +1,8 @@
[Date] [Date]
; Defines the default timezone used by the date functions ; Defines the default timezone used by the date functions
; http://php.net/date.timezone ; http://php.net/date.timezone
date.timezone = Europe/Paris date.timezone = Europe/Paris
[Upload]
upload_max_filesize = 50M
post_max_size = 55M

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,197 @@
# Time Tracking (Toggl-style Timer)
## Résumé
Système de suivi de temps type Toggl intégré à Lesstime. Permet de démarrer des timers depuis les tickets (TaskCard) ou à vide depuis la sidebar, visualiser les temps sur un calendrier semaine/jour, et gérer les entrées de temps (drag, resize, copier-coller).
## Modèle de données
### Entité `TimeEntry`
| Champ | Type | Contraintes |
|-------|------|-------------|
| `id` | integer | PK, auto-increment |
| `title` | string(255) | nullable |
| `description` | text | nullable |
| `startedAt` | datetimetz_immutable | requis (stocké en UTC) |
| `stoppedAt` | datetimetz_immutable | nullable (null = timer actif, stocké en UTC) |
| `user` | ManyToOne → User | requis, CASCADE on delete |
| `project` | ManyToOne → Project | nullable, SET NULL on delete |
| `task` | ManyToOne → Task | nullable, SET NULL on delete |
| `types` | ManyToMany → TaskType | join table `time_entry_task_type` |
### Règles métier
- Un seul timer actif (`stoppedAt = null`) par user à la fois
- `stoppedAt` > `startedAt` si renseigné
- Les entrées de temps peuvent se chevaucher
- Démarrage depuis un ticket : copie `title`, `project`, `task`, `types` depuis la Task. Le `user` est toujours le user connecté (pas l'assignee du ticket)
- Démarrage à vide : seuls `startedAt` et `user` (connecté) sont renseignés, le reste peut être complété après
- Unicité timer actif : index partiel unique sur `(user_id) WHERE stopped_at IS NULL`
- Entrées traversant minuit : tronquées visuellement à la fin du jour, la suite s'affiche dans la colonne du jour suivant
- Toutes les dates sont stockées et échangées en UTC. Le frontend convertit en heure locale pour l'affichage
## API Endpoints
Préfixe `/api`.
### Sécurité / Autorisations
- Tout user authentifié peut lire les entrées de tous les users (filtrage par user côté frontend)
- Un user peut créer/modifier/supprimer ses propres entrées
- Un ROLE_ADMIN peut créer/modifier/supprimer les entrées de n'importe qui
- Assigner un temps à un autre user (`user` ≠ soi-même) requiert ROLE_ADMIN
| Méthode | Route | Description |
|---------|-------|-------------|
| `GET` | `/api/time_entries` | Liste avec filtres : `user`, `project`, `startedAt[after]`, `startedAt[before]`, `types` |
| `POST` | `/api/time_entries` | Créer une entrée ou démarrer un timer |
| `PATCH` | `/api/time_entries/{id}` | Modifier (stopper, compléter, redimensionner, déplacer) |
| `DELETE` | `/api/time_entries/{id}` | Supprimer |
| `GET` | `/api/time_entries/active` | Timer actif du user connecté (custom Provider, `uriTemplate` avec priorité > item route) |
## Frontend
### Store Pinia `useTimerStore`
```typescript
state: {
activeEntry: TimeEntry | null
}
getters: {
isRunning: boolean // activeEntry !== null
elapsed: number // calculé via setInterval: now - activeEntry.startedAt
}
actions: {
fetchActive() // GET /api/time_entries/active — appelé au chargement app
start() // POST à vide (startedAt: now, user: currentUser)
startFromTask(task: Task) // Stoppe le timer actif si existant, puis POST avec données du ticket (user = connecté, pas assignee)
stop() // PATCH stoppedAt: now
}
```
Le temps est fiable même si le navigateur est fermé : `startedAt` est en base, le compteur affiche toujours `now - startedAt` au rechargement.
### Timer dans la sidebar (bas à gauche)
- **Inactif** : affiche `00:00:00` + bouton play (démarrage à vide)
- **Actif** : compteur temps réel + bouton stop
- Toujours visible, dans le layout `default.vue`
### Bouton play sur TaskCard
- Bouton play existant sur les cartes du kanban
- Clic → `timerStore.startFromTask(task)`
- Si un timer est déjà actif : stop automatique de l'ancien, puis démarrage du nouveau
### Page "Suivi des temps"
**Route** : `/time-tracking`
**Lien sidebar** : "Suivi de temps" (icône horloge)
#### Header
- Titre "Suivi des temps"
- Mois/année en orange
- Toggle vue : **Semaine** / **Jour** avec flèches `< >`
- Filtres : **User** (select, défaut = user connecté), **Type** (select TaskType)
- Bouton **"+ Ajouter une Activité"**
#### Grille calendrier
- **Axe Y** : 00:00 → 23:59 (minuit à minuit)
- **Axe X** : 7 colonnes (semaine, Lun→Dim) ou 1 colonne (jour)
- Chaque colonne : jour + date + total heures sous la date
#### Blocs de temps
- **Couleur** = couleur du projet
- **Contenu** : titre, nom du projet (petit), badge type coloré, durée
- Les blocs peuvent se chevaucher
#### Interactions
| Action | Comportement |
|--------|-------------|
| **Clic sur un bloc** | Ouvre le drawer en mode édition |
| **Drag & drop d'un bloc** | Déplacer vers un autre créneau ou autre jour |
| **Resize (bord bas)** | Redimensionner la durée (modifie `stoppedAt`) |
| **Clic sur créneau vide** | Ouvre le drawer en mode création avec heure début pré-remplie |
| **Clic droit sur un bloc** | Menu contextuel : Copier, Supprimer |
| **Clic droit sur créneau vide** | Menu contextuel : Coller (si un bloc copié) |
| **Bouton "+ Ajouter une Activité"** | Ouvre le drawer en mode création |
### Drawer "Ajouter/Modifier un temps"
Utilise le composant `AppDrawer` existant.
**Champs** :
- Titre (input text)
- Description (textarea)
- Heure début (datetime picker)
- Heure fin (datetime picker)
- User (select, défaut = user connecté, peut assigner à un autre)
- Projet (select)
- Type (select TaskType)
- Bouton Enregistrer
En mode édition : champs pré-remplis avec les données du TimeEntry.
## Service frontend
### `useTimeEntryService()`
```typescript
getByDateRange(params: { after: string, before: string, user?: number, types?: number[] }): Promise<TimeEntry[]>
getActive(): Promise<TimeEntry | null>
create(payload: TimeEntryWrite): Promise<TimeEntry>
update(id: number, payload: Partial<TimeEntryWrite>): Promise<TimeEntry>
remove(id: number): Promise<void>
```
### DTO `TimeEntry`
```typescript
type TimeEntry = {
id: number
'@id'?: string
title: string | null
description: string | null
startedAt: string // ISO datetime
stoppedAt: string | null // null = timer actif
user: UserData
project: Project | null
task: Task | null
types: TaskType[]
}
type TimeEntryWrite = {
title?: string | null
description?: string | null
startedAt: string
stoppedAt?: string | null
user: string // IRI
project?: string | null // IRI
task?: string | null // IRI
types?: string[] // IRIs
}
```
## Modifications sur l'existant
- **DTO `Task`** : ajouter le champ `project: Project` (nécessaire pour `startFromTask`)
- **`TaskCard.vue`** : connecter le bouton play existant à `timerStore.startFromTask(task)`
- **`default.vue`** : intégrer `SidebarTimer.vue` en bas de la sidebar (au-dessus du bouton collapse). En mode collapsed : afficher uniquement le bouton play/stop sans le compteur texte
- **Sidebar links** : ajouter le lien "Suivi de temps" vers `/time-tracking`
## Composants frontend
| Composant | Rôle |
|-----------|------|
| `TimeTrackingCalendar.vue` | Grille calendrier (semaine/jour) avec blocs |
| `TimeEntryBlock.vue` | Bloc de temps individuel (drag, resize) |
| `TimeEntryDrawer.vue` | Drawer ajout/modification |
| `TimeEntryContextMenu.vue` | Menu contextuel (copier, coller, supprimer) |
| `SidebarTimer.vue` | Widget timer dans la sidebar |

View File

@@ -0,0 +1,316 @@
# BookStack Connector — Design Spec
**Date:** 2026-03-15
**BookStack version:** v25.12.8
**Pattern:** Mirror of Gitea connector
## Overview
Connecteur BookStack permettant de lier des documents (pages et livres) du wiki à des tâches Lesstime. Chaque projet peut être associé à une étagère (shelf) BookStack, et les utilisateurs peuvent rechercher et lier des pages/livres de cette étagère à leurs tâches.
## Périmètre
- Types liés : **pages** et **livres** (books)
- Niveau projet : liaison à une **étagère** (shelf)
- Niveau tâche : liaison à une ou plusieurs **pages/livres** de l'étagère du projet
- Recherche : filtrée dans l'étagère du projet uniquement
- Stockage : **référence** (titre + URL), pas d'aperçu du contenu
- Auth BookStack : Token ID + Token Secret (header `Authorization: Token {id}:{secret}`)
## Backend
### Entités
#### BookStackConfiguration (singleton)
```php
// src/Entity/BookStackConfiguration.php
class BookStackConfiguration
{
private ?int $id;
private ?string $url = null;
private ?string $encryptedTokenId = null;
private ?string $encryptedTokenSecret = null;
public function hasToken(): bool; // vérifie que les deux sont présents
}
```
- Chiffrement via `TokenEncryptor` existant (même pattern que Gitea)
- Repository avec `findSingleton()`
#### TaskBookStackLink
```php
// src/Entity/TaskBookStackLink.php
class TaskBookStackLink
{
private ?int $id;
private Task $task; // ManyToOne, CASCADE on delete
private int $bookstackId; // ID dans BookStack
private string $bookstackType; // 'page' | 'book'
private string $title; // titre au moment du lien (cache)
private string $url; // URL complète
private \DateTimeImmutable $createdAt;
}
```
#### Project (extension)
Ajout de deux champs :
- `bookstackShelfId` (nullable int)
- `bookstackShelfName` (nullable string) — cache du nom pour affichage
### Service
#### BookStackApiService
```php
// src/Service/BookStackApiService.php
class BookStackApiService
{
public function testConnection(): bool;
public function listShelves(): array;
public function searchInShelf(int $shelfId, string $query): array;
public function getPage(int $id): array;
public function getBook(int $id): array;
}
```
- Utilise `HttpClientInterface` (Symfony HttpClient)
- Auth : header `Authorization: Token {tokenId}:{tokenSecret}`
- Timeout : 10 secondes
- `testConnection()` : GET `/api/docs.json`
- `listShelves()` : GET `/api/shelves` (paginé via `count`/`offset`, pas `page`/`limit` — spécificité BookStack)
- `searchInShelf()` : algorithme en 3 étapes :
1. GET `/api/shelves/{shelfId}` → récupère la liste des `books` de l'étagère (IDs)
2. GET `/api/search?query={query} {type:page|book}` → recherche globale (espace entre query et filtre type, BookStack syntax)
3. Filtre côté PHP : pour les **books**, vérifie que `book.id` est dans la liste de l'étagère ; pour les **pages**, vérifie que `page.book_id` est dans la liste. Exclut les résultats `chapter` et `bookshelf`.
- Note : la liste des books de l'étagère peut être cachée en mémoire pour la durée de la requête.
- `getPage()` : GET `/api/pages/{id}`
- `getBook()` : GET `/api/books/{id}`
#### BookStackApiException
```php
// src/Exception/BookStackApiException.php
class BookStackApiException extends \RuntimeException {}
```
### API Resources & Endpoints
#### Admin
| Méthode | Route | Ressource API Platform | Sécurité |
|---------|-------|----------------------|----------|
| GET | `/api/settings/bookstack` | BookStackSettings | ROLE_ADMIN |
| PUT | `/api/settings/bookstack` | BookStackSettings | ROLE_ADMIN |
| POST | `/api/settings/bookstack/test` | BookStackTestConnection | ROLE_ADMIN |
**BookStackSettings** (DTO) :
- Read : `url`, `hasToken`
- Write : `url`, `tokenId`, `tokenSecret`
**BookStackTestConnection** (DTO) :
- Read : `success`
#### Projet
| Méthode | Route | Ressource API Platform | Sécurité |
|---------|-------|----------------------|----------|
| GET | `/api/bookstack/shelves` | BookStackShelf | ROLE_ADMIN |
**BookStackShelf** (DTO) :
- Read : `id`, `name`
L'étagère sélectionnée est sauvée via le PATCH existant de Project (`bookstackShelfId`, `bookstackShelfName`).
#### Tâche
| Méthode | Route | Ressource API Platform | Sécurité |
|---------|-------|----------------------|----------|
| GET | `/api/tasks/{taskId}/bookstack/links` | BookStackLink | Authenticated |
| POST | `/api/tasks/{taskId}/bookstack/links` | BookStackLink | Authenticated |
| DELETE | `/api/tasks/{taskId}/bookstack/links/{id}` | BookStackLink | Authenticated |
| GET | `/api/tasks/{taskId}/bookstack/search?q=` | BookStackSearchResult | Authenticated |
**BookStackLink** (DTO) :
- Read : `id`, `bookstackId`, `bookstackType`, `title`, `url`, `createdAt`
- Write : `bookstackId`, `bookstackType`, `title`, `url`
**BookStackSearchResult** (DTO) :
- Read : `id`, `type`, `name`, `url`
### State Providers / Processors
| Classe | Rôle |
|--------|------|
| `BookStackSettingsProvider` | Lit config singleton, retourne DTO masqué |
| `BookStackSettingsProcessor` | Persiste config, chiffre tokens |
| `BookStackTestConnectionProvider` | Appelle `testConnection()` |
| `BookStackShelfProvider` | Appelle `listShelves()`, mappe en DTOs |
| `BookStackLinkProvider` | Lit `TaskBookStackLink` par task ID |
| `BookStackLinkProcessor` | POST : crée lien en DB / DELETE : supprime |
| `BookStackSearchResultProvider` | Appelle `searchInShelf()`, mappe en DTOs |
### Migration
```sql
CREATE TABLE bookstack_configuration (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
url VARCHAR(255) DEFAULT NULL,
encrypted_token_id TEXT DEFAULT NULL,
encrypted_token_secret TEXT DEFAULT NULL
);
CREATE TABLE task_bookstack_link (
id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
task_id INT NOT NULL REFERENCES task(id) ON DELETE CASCADE,
bookstack_id INT NOT NULL,
bookstack_type VARCHAR(10) NOT NULL,
title VARCHAR(255) NOT NULL,
url VARCHAR(500) NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
);
CREATE INDEX IDX_task_bookstack_link_task_id ON task_bookstack_link (task_id);
CREATE UNIQUE INDEX UNIQ_task_bookstack_link ON task_bookstack_link (task_id, bookstack_id, bookstack_type);
ALTER TABLE project ADD bookstack_shelf_id INT DEFAULT NULL;
ALTER TABLE project ADD bookstack_shelf_name VARCHAR(255) DEFAULT NULL;
```
### Variable d'environnement
Prérequis : renommer `GITEA_ENCRYPTION_KEY` en `ENCRYPTION_KEY` (générique) dans `TokenEncryptor`, `.env`, et `docker/.env.docker`. Mettre à jour le message d'erreur dans `TokenEncryptor`. Cela permet de réutiliser le même service pour chiffrer les tokens BookStack (deux appels `encrypt()`/`decrypt()` : un pour tokenId, un pour tokenSecret).
### Notes techniques
- `BookStackTestConnectionProvider` implémente à la fois `ProviderInterface` et `ProcessorInterface` (même pattern que `GiteaTestConnectionProvider`)
- Les endpoints collection du frontend utilisent `extractHydraMembers()` pour extraire les résultats des réponses Hydra
- Les titres/URLs stockés dans `TaskBookStackLink` sont des snapshots au moment du lien — pas de rafraîchissement automatique (intentionnel)
- Le select étagère dans `ProjectDrawer` n'est affiché que pour les admins (endpoint `ROLE_ADMIN`)
## Frontend
### Service
```typescript
// frontend/services/bookstack.ts
export function useBookStackService() {
// Admin
async function getSettings(): Promise<BookStackSettings>
async function saveSettings(payload: BookStackSettingsWrite): Promise<BookStackSettings>
async function testConnection(): Promise<BookStackTestResult>
// Projet
async function listShelves(): Promise<BookStackShelf[]>
// Tâche
async function getLinks(taskId: number): Promise<BookStackLink[]>
async function addLink(taskId: number, payload: BookStackLinkCreate): Promise<BookStackLink>
async function removeLink(taskId: number, linkId: number): Promise<void>
async function search(taskId: number, query: string): Promise<BookStackSearchResult[]>
}
```
### DTOs
```typescript
// frontend/services/dto/bookstack.ts
type BookStackSettings = { url: string | null; hasToken: boolean }
type BookStackSettingsWrite = { url: string | null; tokenId: string | null; tokenSecret: string | null }
type BookStackTestResult = { success: boolean }
type BookStackShelf = { id: number; name: string }
type BookStackLink = { id: number; bookstackId: number; bookstackType: 'page' | 'book'; title: string; url: string; createdAt: string }
type BookStackLinkCreate = { bookstackId: number; bookstackType: 'page' | 'book'; title: string; url: string }
type BookStackSearchResult = { id: number; type: 'page' | 'book'; name: string; url: string }
```
### Composants
#### AdminBookStackTab.vue
Onglet admin (même pattern que `AdminGiteaTab.vue`) :
- Champs : URL, Token ID, Token Secret
- Bouton "Tester la connexion" avec indicateur résultat
- Indicateur "Token configuré" (ne montre jamais le token)
- Sauvegarde via `saveSettings()`
#### ProjectDrawer.vue (extension)
- Si BookStack est configuré : select pour choisir une étagère
- Charge `listShelves()` à l'ouverture
- Sauvegarde `bookstackShelfId` + `bookstackShelfName` sur le projet via PATCH
#### TaskBookStackLinks.vue
Petit composant intégré dans `TaskModal.vue`, visible directement :
- **Input de recherche** avec debounce (~300ms) → appel `search(taskId, query)` → dropdown résultats
- Chaque résultat : icône (page 📄 / livre 📕) + titre — clic pour ajouter
- **Liste des liens** sous le champ recherche : icône type + titre cliquable (ouvre BookStack dans nouvel onglet) + bouton × supprimer
- Affiché uniquement si le projet de la tâche a une shelf BookStack configurée
- Charge les liens existants au mount via `getLinks(taskId)`
#### TaskModal.vue (extension)
- Ajoute `<TaskBookStackLinks>` dans le modal, conditionné par `project.bookstackShelfId`
- Passe `taskId` et `projectId` en props
## Fichiers à créer/modifier
### Backend — Nouveaux fichiers
```
src/Entity/BookStackConfiguration.php
src/Entity/TaskBookStackLink.php
src/Repository/BookStackConfigurationRepository.php
src/Repository/TaskBookStackLinkRepository.php
src/Service/BookStackApiService.php
src/Exception/BookStackApiException.php
src/ApiResource/BookStackSettings.php
src/ApiResource/BookStackTestConnection.php
src/ApiResource/BookStackShelf.php
src/ApiResource/BookStackLink.php
src/ApiResource/BookStackSearchResult.php
src/State/BookStackSettingsProvider.php
src/State/BookStackSettingsProcessor.php
src/State/BookStackTestConnectionProvider.php
src/State/BookStackShelfProvider.php
src/State/BookStackLinkProvider.php
src/State/BookStackLinkProcessor.php
src/State/BookStackSearchResultProvider.php
migrations/VersionXXXX.php
```
### Backend — Fichiers modifiés
```
src/Entity/Project.php (ajout bookstackShelfId, bookstackShelfName)
src/Service/TokenEncryptor.php (renommage GITEA_ENCRYPTION_KEY → ENCRYPTION_KEY)
```
### Config — Fichiers modifiés
```
.env (renommage GITEA_ENCRYPTION_KEY → ENCRYPTION_KEY)
```
> Note : `docker/.env.docker` ne contient pas `GITEA_ENCRYPTION_KEY`. Les développeurs utilisant `docker/.env.docker.local` doivent le mettre à jour manuellement.
### Frontend — Nouveaux fichiers
```
frontend/services/bookstack.ts
frontend/services/dto/bookstack.ts
frontend/components/admin/AdminBookStackTab.vue
frontend/components/task/TaskBookStackLinks.vue
```
### Frontend — Fichiers modifiés
```
frontend/components/task/TaskModal.vue (ajout TaskBookStackLinks)
frontend/components/project/ProjectDrawer.vue (ajout select étagère)
frontend/components/admin/ (ajout onglet BookStack dans la page admin)
```

View File

@@ -0,0 +1,523 @@
# Portail Client — Design Spec
## Résumé
Ajout d'un portail client dans Lesstime permettant aux utilisateurs-clients de soumettre des tickets (bug, amélioration, autre) sur leurs projets, suivre l'évolution de leur traitement, et joindre des documents. Les utilisateurs internes (ROLE_ADMIN, ROLE_USER) gèrent les tickets côté admin et peuvent les lier manuellement à des tasks existantes. Un système de notifications in-app informe les parties prenantes des événements clés.
## Décisions d'architecture
- **ClientTicket est une entité séparée de Task** — cycle de vie indépendant, meilleure séparation de sécurité, maintenance simplifiée
- **Même application, vue adaptée par rôle** — pas de portail séparé. ROLE_CLIENT voit les pages `/portal`, ROLE_ADMIN/ROLE_USER voit l'app interne
- **Pas de commentaires/échanges** — communication unidirectionnelle : le client soumet, voit les changements de statut, c'est tout
- **Notifications in-app uniquement** — pas d'email pour le moment
- **Lien ticket-task manuel** — le manager crée des tasks et les lie explicitement à un ticket client
- **TaskDocument conservée** — l'entité `TaskDocument` n'est pas renommée, elle est généralisée avec un champ `clientTicket` nullable
- **Français uniquement** — l'interface est en français pour le moment, l'anglais pourra être ajouté plus tard
## Prérequis : sécurisation des endpoints existants
Avant l'introduction du rôle `ROLE_CLIENT`, il faut sécuriser l'application existante.
### Modification de `User::getRoles()`
Actuellement, `User::getRoles()` ajoute inconditionnellement `ROLE_USER` à tous les utilisateurs. Un utilisateur `ROLE_CLIENT` hériterait donc de `ROLE_USER` et pourrait accéder à toutes les données internes.
**Correction** : `getRoles()` doit ajouter `ROLE_USER` uniquement si l'utilisateur n'a PAS le rôle `ROLE_CLIENT` :
```php
public function getRoles(): array
{
$roles = $this->roles;
if (!in_array('ROLE_CLIENT', $roles, true)) {
$roles[] = 'ROLE_USER';
}
return array_unique($roles);
}
```
### Ajout de `security` sur les endpoints existants
Les endpoints existants suivants n'ont pas d'annotation `security` explicite et doivent recevoir `security: "is_granted('ROLE_USER')"` sur leurs opérations `GetCollection` et `Get` :
| Entité | Opérations à sécuriser |
|--------|----------------------|
| `Task` | GetCollection, Get |
| `Project` | GetCollection, Get |
| `Client` | GetCollection, Get |
| `TaskStatus` | GetCollection, Get |
| `TaskEffort` | GetCollection, Get |
| `TaskPriority` | GetCollection, Get |
| `TaskTag` | GetCollection, Get |
| `TaskGroup` | GetCollection, Get |
| `TimeEntry` | GetCollection, Get |
Cela garantit qu'un utilisateur `ROLE_CLIENT` ne peut pas accéder aux ressources internes via l'API.
## Modèle de données
### Entité `ClientTicket`
| Champ | Type | Description |
|-------|------|-------------|
| `id` | int (auto) | Clé primaire |
| `number` | int | Auto-généré, unique par projet (voir stratégie ci-dessous) |
| `type` | string (enum) | `bug`, `improvement`, `other` |
| `title` | string | Requis |
| `description` | text | Requis |
| `url` | string (nullable) | Affiché uniquement si `type = bug` |
| `status` | string (enum) | `new`, `in_progress`, `done`, `rejected` |
| `statusComment` | text (nullable) | Commentaire du manager lors d'un changement de statut |
| `project` | ManyToOne → Project | Requis |
| `submittedBy` | ManyToOne → User (nullable) | L'utilisateur-client ayant soumis le ticket. **ON DELETE SET NULL** — ne pas détruire l'historique lors de la suppression d'un utilisateur |
| `createdAt` | DateTimeImmutable | Auto |
| `updatedAt` | DateTimeImmutable | Auto |
#### Stratégie de numérotation
Numéro incrémental par projet : `SELECT MAX(number) + 1 FROM client_ticket WHERE project_id = :project`. Contrainte unique sur `(project_id, number)` avec retry en cas de conflit (concurrent insert). Le numéro affiché sera formaté `CT-001`, `CT-002`, etc. en frontend.
### Statuts des tickets (enum fixe, non configurable)
| Statut | Description |
|--------|-------------|
| `new` | Ticket venant d'être soumis |
| `in_progress` | Pris en charge par un manager |
| `done` | Résolu, client notifié |
| `rejected` | Non retenu — `statusComment` obligatoire |
#### Transitions de statut autorisées
Toutes les transitions sont autorisées, **sauf** :
- `done``new` (interdit)
- `rejected``new` (interdit)
Un ticket `done` peut repasser en `in_progress` si besoin. Un ticket `rejected` peut passer en `in_progress`. Le Processor valide les transitions et rejette les transitions interdites.
### Entité `Notification`
| Champ | Type | Description |
|-------|------|-------------|
| `id` | int (auto) | Clé primaire |
| `user` | ManyToOne → User | Destinataire |
| `type` | string | `ticket_created`, `ticket_status_changed` |
| `title` | string | Titre court |
| `message` | text | Contenu |
| `relatedTicket` | ManyToOne → ClientTicket (nullable) | Lien vers le ticket concerné |
| `isRead` | bool | `false` par défaut |
| `createdAt` | DateTimeImmutable | Auto |
### Modifications sur `User`
| Champ | Type | Description |
|-------|------|-------------|
| `client` | ManyToOne → Client (nullable) | `null` = utilisateur interne, set = utilisateur-client |
| `allowedProjects` | ManyToMany → Project | Projets auxquels l'utilisateur-client a accès |
Nouveau rôle : `ROLE_CLIENT`
#### Groupes de sérialisation
| Champ | Groupes |
|-------|---------|
| `client` | `me:read`, `user:read`, `user:write` |
| `allowedProjects` | `me:read`, `user:read`, `user:write` |
Règles :
- Plusieurs utilisateurs par client (1+)
- Les utilisateurs-clients sont assignés à des projets spécifiques (pas tous les projets du client)
- L'admin crée les comptes utilisateurs-clients (pas d'auto-inscription)
### Modifications sur `Task`
| Champ | Type | Description |
|-------|------|-------------|
| `clientTicket` | ManyToOne → ClientTicket (nullable) | Lien vers un ticket client |
Le champ `clientTicket` est exposé dans le groupe `task:read` avec les informations de base du ticket (number, type, status, title). Cela permet aux utilisateurs ROLE_USER d'afficher l'icône et le tooltip dans le kanban sans avoir accès à la collection `/api/client_tickets`.
### Généralisation de `TaskDocument`
L'entité `TaskDocument` existante est **conservée** (pas de renommage) et généralisée avec un champ supplémentaire :
| Champ | Modification |
|-------|-------------|
| `task` | Devient nullable |
| `clientTicket` | ManyToOne → ClientTicket (nullable) — ajouté |
**Contrainte** : au moins un des deux champs `task` / `clientTicket` doit être renseigné (CHECK constraint en base).
**Processor** : généralisé pour accepter `task` OU `clientTicket` dans le FormData.
**Sécurité** :
- ROLE_ADMIN : accès complet à tous les documents
- ROLE_USER : accès aux documents liés à une task (`task IS NOT NULL`)
- ROLE_CLIENT : accès aux documents liés à un ticket dont l'utilisateur est le `submittedBy`
## API Endpoints
Préfixe `/api`.
### ClientTicket
| Méthode | Route | Accès | Notes |
|---------|-------|-------|-------|
| `GET` | `/api/client_tickets` | ROLE_CLIENT : ses propres tickets ; ROLE_ADMIN : tous | Filtres : `project`, `status`, `submittedBy` |
| `GET` | `/api/client_tickets/{id}` | Owner ou ROLE_ADMIN | |
| `POST` | `/api/client_tickets` | ROLE_CLIENT | `submittedBy` auto-set depuis le token JWT. Le Processor valide que `user.client` n'est pas null (empêche un admin de créer un ticket même via la hiérarchie de rôles) |
| `PATCH` | `/api/client_tickets/{id}` | ROLE_ADMIN uniquement | Changement de statut + `statusComment` |
| `DELETE` | `/api/client_tickets/{id}` | ROLE_ADMIN | Cascade sur les documents liés |
**Note** : ROLE_USER n'a PAS accès à la collection `/api/client_tickets`. L'accès en lecture aux informations d'un ticket se fait via le champ `task.clientTicket` exposé dans le groupe `task:read`.
### Notification
| Méthode | Route | Accès | Notes |
|---------|-------|-------|-------|
| `GET` | `/api/notifications` | Authentifié | Auto-filtré par l'utilisateur courant. Paginé : 30 par page |
| `PATCH` | `/api/notifications/{id}` | Owner | Marquer comme lu |
| `POST` | `/api/notifications/mark-all-read` | Authentifié | **Endpoint Symfony custom** (controller dédié, pas une opération API Platform) |
| `GET` | `/api/notifications/unread-count` | Authentifié | Retourne le count |
**Nettoyage** : prévoir un cron de purge ultérieur (suppression des notifications > 90 jours). Pas implémenté dans la première version.
### TaskDocument
- Les endpoints existants restent, avec ajout du filtre `clientTicket`
- Le Processor accepte `task` OU `clientTicket`
- Sécurité : ROLE_ADMIN (tous), ROLE_USER (documents liés à une task), ROLE_CLIENT (documents liés à un ticket dont l'utilisateur est le `submittedBy`)
## State Providers & Processors
### `ClientTicketProvider`
- ROLE_CLIENT : filtre par `submittedBy` = utilisateur courant
- ROLE_ADMIN : retourne tous les tickets
- Vérifie que l'utilisateur-client a accès au projet du ticket (via `allowedProjects`)
### `ClientTicketNumberProcessor`
- Sur `POST` : auto-génère le numéro via `SELECT MAX(number) FROM client_ticket WHERE project_id = :project` + 1, avec contrainte unique `(project_id, number)` et retry en cas de conflit
- Valide que `user.client` n'est pas null (empêche la création par un admin même si ROLE_ADMIN hérite de ROLE_CLIENT)
- Set `submittedBy` depuis le token JWT courant
- Set `status` à `new`
- Set `createdAt` et `updatedAt`
### `ClientTicketStatusProcessor`
- Sur `PATCH` : valide la transition de statut
- Transitions interdites : `done``new`, `rejected``new`
- `statusComment` obligatoire si le nouveau statut est `rejected`
- Met à jour `updatedAt`
### `ClientTicketNotificationProcessor`
- Sur `POST` (ticket créé) : crée une `Notification` pour tous les utilisateurs ROLE_ADMIN
- Type : `ticket_created`
- Title : "Nouveau ticket client CT-XXX"
- Message : titre du ticket + nom du projet
- Sur `PATCH` (changement de statut) : crée une `Notification` pour le `submittedBy`
- Type : `ticket_status_changed`
- Title : "Ticket CT-XXX mis à jour"
- Message : nouveau statut + `statusComment` si présent
### `NotificationProvider`
- Toujours filtré par l'utilisateur courant (`user` = token JWT)
- Paginé : 30 résultats par page
- Endpoint `unread-count` : `SELECT COUNT(*) WHERE user = :user AND isRead = false`
### `MarkAllReadController`
Endpoint custom Symfony (`POST /api/notifications/mark-all-read`) :
- Récupère l'utilisateur depuis le token JWT
- Exécute `UPDATE notification SET is_read = true WHERE user_id = :user AND is_read = false`
- Retourne `204 No Content`
## Frontend
### Routing & Middleware
Modification de `auth.global.ts` :
- ROLE_CLIENT → redirigé vers `/portal`, accès bloqué à `/projects`, `/admin`, `/time-tracking`, etc.
- ROLE_ADMIN / ROLE_USER → peut accéder à `/portal` pour voir la vue côté client
### Pages du portail
#### `/portal` — Liste des projets
- Affiche les projets auxquels l'utilisateur-client a accès (`allowedProjects`)
- Cartes simples : nom du projet, nombre de tickets ouverts
- Clic → `/portal/projects/{id}`
#### `/portal/projects/{id}` — Tickets d'un projet
- Liste des tickets soumis sur ce projet
- Pour chaque ticket : numéro (CT-XXX), type badge, titre, statut badge, date de création
- Bouton "Nouveau ticket" → `/portal/projects/{id}/new-ticket`
- Clic sur un ticket → modale de détail (lecture seule : titre, description, url, statut, statusComment, documents)
#### `/portal/projects/{id}/new-ticket` — Formulaire de création
- Select type : `bug`, `improvement`, `other`
- Champ title (requis)
- Champ description (requis, textarea)
- Champ url (affiché uniquement si `type = bug`)
- Zone d'upload de documents (réutilise les composants TaskDocument existants)
- Bouton soumettre
### Modifications des pages existantes
#### Kanban (`/projects/{id}`)
- Icône `heroicons:user-circle` affichée à côté du titre de la task si `task.clientTicket` est set
- Tooltip au survol : "Lié au ticket client CT-XXX" (données disponibles via `task:read`)
#### `/my-tasks`
- Même icône et tooltip que le kanban
#### `/admin` — Nouvel onglet "Tickets client"
- Liste de tous les tickets, avec filtres par projet et statut
- Pour chaque ticket : numéro, type, titre, statut, projet, soumis par, date
- Actions :
- Changer le statut (select + champ statusComment si rejection)
- Voir le détail du ticket (modale avec documents)
### Services API
#### `frontend/services/client-tickets.ts`
```typescript
getAll(params?: { project?: number; status?: string; submittedBy?: number }): Promise<ClientTicket[]>
getById(id: number): Promise<ClientTicket>
create(data: { type: string; title: string; description: string; url?: string; project: string }): Promise<ClientTicket>
updateStatus(id: number, data: { status: string; statusComment?: string }): Promise<ClientTicket>
remove(id: number): Promise<void>
```
#### `frontend/services/notifications.ts`
```typescript
getAll(page?: number): Promise<Notification[]>
markAsRead(id: number): Promise<void>
markAllAsRead(): Promise<void>
getUnreadCount(): Promise<number>
```
### DTOs TypeScript
#### `frontend/services/dto/client-ticket.ts`
```typescript
type ClientTicketType = 'bug' | 'improvement' | 'other'
type ClientTicketStatus = 'new' | 'in_progress' | 'done' | 'rejected'
type ClientTicket = {
'@id'?: string
id: number
number: number
type: ClientTicketType
title: string
description: string
url: string | null
status: ClientTicketStatus
statusComment: string | null
project: string // IRI
submittedBy: string | null // IRI, nullable (ON DELETE SET NULL)
createdAt: string
updatedAt: string
documents?: TaskDocument[]
}
```
#### `frontend/services/dto/notification.ts`
```typescript
type NotificationType = 'ticket_created' | 'ticket_status_changed'
type Notification = {
'@id'?: string
id: number
user: string // IRI
type: NotificationType
title: string
message: string
relatedTicket: string | null // IRI
isRead: boolean
createdAt: string
}
```
### Composants réutilisés
- `TaskDocumentUpload` → généralisé avec prop `clientTicketId` comme alternative à `taskId`
- `TaskDocumentList` + `TaskDocumentPreview` → réutilisés dans la modale de détail du ticket
### Composants à créer
#### `frontend/components/notification/NotificationBell.vue`
- Placé dans le header de la navbar
- Icône cloche avec badge rouge (nombre de notifications non lues)
- Clic → dropdown avec les notifications récentes (paginé, 30 par page)
- Chaque notification : titre, message (tronqué), date relative, indicateur lu/non-lu
- Clic sur une notification → marque comme lue + navigation vers le ticket lié
- Bouton "Tout marquer comme lu"
### Composable `useNotifications()`
```typescript
const useNotifications = () => {
const unreadCount: Ref<number>
const notifications: Ref<Notification[]>
const fetchNotifications: (page?: number) => Promise<void>
const fetchUnreadCount: () => Promise<void>
const markAsRead: (id: number) => Promise<void>
const markAllAsRead: () => Promise<void>
// Polling toutes les 2 minutes
const startPolling: () => void
const stopPolling: () => void
}
```
Le polling démarre au montage de `NotificationBell` et s'arrête au démontage.
### Clés i18n
Ajouter dans `frontend/i18n/locales/fr.json` (français uniquement pour le moment) :
```
# Portal
portal.title → "Portail client"
portal.projects → "Mes projets"
portal.openTickets → "tickets ouverts"
portal.newTicket → "Nouveau ticket"
portal.ticketDetail → "Détail du ticket"
# Client Ticket
clientTicket.type.bug → "Bug"
clientTicket.type.improvement → "Amélioration"
clientTicket.type.other → "Autre"
clientTicket.status.new → "Nouveau"
clientTicket.status.in_progress → "En cours"
clientTicket.status.done → "Terminé"
clientTicket.status.rejected → "Rejeté"
clientTicket.title → "Titre"
clientTicket.description → "Description"
clientTicket.url → "URL (page concernée)"
clientTicket.statusComment → "Commentaire de statut"
clientTicket.created → "Ticket créé"
clientTicket.statusChanged → "Statut mis à jour"
clientTicket.confirmDelete → "Supprimer ce ticket ?"
clientTicket.linkedTooltip → "Lié au ticket client {number}"
clientTicket.rejectionRequired → "Un commentaire est requis pour rejeter un ticket"
# Notifications
notification.title → "Notifications"
notification.markAllRead → "Tout marquer comme lu"
notification.empty → "Aucune notification"
notification.ticketCreated → "Nouveau ticket client {number}"
notification.ticketStatusChanged → "Ticket {number} mis à jour"
```
## Migration
### Nouvelles tables
**`client_ticket`** :
- Colonnes correspondant à l'entité `ClientTicket`
- Index sur `project_id`
- Index sur `submitted_by_id`
- Index composite sur `(status, project_id)` pour les filtres admin
- Contrainte unique sur `(project_id, number)` pour la numérotation par projet
- FK `project_id``project.id` ON DELETE CASCADE
- FK `submitted_by_id``user.id` **ON DELETE SET NULL**
**`notification`** :
- Colonnes correspondant à l'entité `Notification`
- Index sur `user_id`
- Index composite sur `(user_id, is_read)` pour le count non-lu
- FK `user_id``user.id` ON DELETE CASCADE
- FK `related_ticket_id``client_ticket.id` ON DELETE SET NULL
**`user_allowed_projects`** (table de jointure ManyToMany) :
- `user_id``user.id` ON DELETE CASCADE
- `project_id``project.id` ON DELETE CASCADE
### Modifications de tables existantes
**`user`** :
- Ajout colonne `client_id` (nullable) — FK → `client.id` ON DELETE SET NULL
**`task`** :
- Ajout colonne `client_ticket_id` (nullable) — FK → `client_ticket.id` ON DELETE SET NULL
**`task_document`** (table conservée, pas de renommage) :
- Colonne `task_id` devient nullable
- Ajout colonne `client_ticket_id` (nullable) — FK → `client_ticket.id` ON DELETE CASCADE
- Contrainte CHECK : `task_id IS NOT NULL OR client_ticket_id IS NOT NULL`
## Sécurité
### Hiérarchie des rôles
```yaml
# config/packages/security.yaml
security:
role_hierarchy:
ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]
```
### Contrôle d'accès
| Ressource | ROLE_CLIENT | ROLE_USER | ROLE_ADMIN |
|-----------|-------------|-----------|------------|
| ClientTicket (ses propres) | Lecture + Création | Lecture via `task:read` (champ `task.clientTicket`) | CRUD complet |
| ClientTicket collection `/api/client_tickets` | Ses propres tickets | — | Tous |
| Notification (ses propres) | Lecture + Mark as read | Lecture + Mark as read | Lecture + Mark as read |
| TaskDocument (lié à une task) | — | Lecture | CRUD complet |
| TaskDocument (lié à un ticket) | Lecture + Upload (si `submittedBy` = soi) | — | CRUD complet |
| Task, Project, Client, TimeEntry, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup | — | Accès normal (`is_granted('ROLE_USER')`) | Accès normal |
| Pages /portal | Accès | Accès | Accès |
| Pages /projects, /admin | — | Accès | Accès |
### Validation du Provider ClientTicket
- ROLE_CLIENT : vérifie que le projet du ticket fait partie de `allowedProjects` de l'utilisateur
- ROLE_CLIENT : ne peut voir que les tickets où `submittedBy` = lui-même
- ROLE_ADMIN : aucune restriction
### Validation du Processor ClientTicket (POST)
- Vérifie que `user.client` n'est pas null — un utilisateur admin ne peut pas créer de ticket même s'il hérite de ROLE_CLIENT via la hiérarchie de rôles
## Phases de livraison
### Phase 1 — Fondations
1. **Prérequis sécurité** : modifier `User::getRoles()` pour ne plus ajouter `ROLE_USER` aux utilisateurs `ROLE_CLIENT` ; ajouter `security: "is_granted('ROLE_USER')"` sur les opérations GetCollection/Get de Task, Project, Client, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry
2. Modifier `User` : ajouter `client` (ManyToOne → Client, nullable), `allowedProjects` (ManyToMany → Project), rôle `ROLE_CLIENT`, groupes de sérialisation `me:read`, `user:read`, `user:write`
3. Généraliser `TaskDocument` : `task` devient nullable, ajout `clientTicket` (ManyToOne → ClientTicket, nullable), contrainte CHECK, Processor généralisé
4. Créer l'entité `ClientTicket` + migration (avec contrainte unique `(project_id, number)`)
5. API CRUD `ClientTicket` avec sécurité (Provider, Processor, validation `user.client` sur POST, validation des transitions de statut sur PATCH)
6. Admin : gestion des utilisateurs-clients (créer un user avec ROLE_CLIENT, lié à un client + projets autorisés)
### Phase 2 — Portail client
1. Pages `/portal`, `/portal/projects/{id}`, formulaire de création de ticket
2. Upload de documents sur les tickets (réutilisation des composants TaskDocument existants, généralisés avec prop `clientTicketId`)
3. Lien `Task.clientTicket` + icône dans le kanban et `/my-tasks` (données via `task:read`)
4. Admin : onglet tickets client (liste, changement de statut)
### Phase 3 — Notifications
1. Entité `Notification` + API (paginé, 30 par page)
2. `MarkAllReadController` — endpoint Symfony custom (`POST /api/notifications/mark-all-read`)
3. Auto-création des notifications dans le `ClientTicketNotificationProcessor`
4. `NotificationBell.vue` avec polling toutes les 2 minutes
5. Composable `useNotifications()`
6. Note : prévoir un cron de purge ultérieur (suppression des notifications > 90 jours)

View File

@@ -0,0 +1,218 @@
# Task Documents — Design Spec
## Overview
Ajout d'un système de documents attachés aux tickets (tasks). Les utilisateurs peuvent uploader des fichiers via drag & drop ou sélection, les visualiser (images, PDF) dans une modale plein écran, et les télécharger.
## Contraintes
- **Taille max par fichier** : 50 Mo
- **Types acceptés** : tous types de fichiers
- **Nombre par ticket** : illimité
- **Stockage** : filesystem local (`var/uploads/documents/`)
- **Permissions** : ROLE_ADMIN pour créer/supprimer, ROLE_USER pour lire
- **Contexte** : application single-tenant, tous les utilisateurs voient tous les projets — pas de scoping projet
## Backend
### Entité `TaskDocument`
| Champ | Type | Description |
|-------|------|-------------|
| `id` | int (auto) | Clé primaire |
| `task` | ManyToOne → Task | Ticket parent (CASCADE on delete) |
| `originalName` | string (255) | Nom original du fichier uploadé |
| `fileName` | string (255) | Nom unique sur disque (`{uuid}.{extension}`) |
| `mimeType` | string (100) | Type MIME (ex: `image/png`, `application/pdf`) |
| `size` | int | Taille en octets |
| `createdAt` | DateTimeImmutable | Date d'upload |
| `uploadedBy` | ManyToOne → User | Utilisateur ayant uploadé (SET NULL on delete) |
### Relation inverse sur Task
- `Task.documents` : OneToMany → TaskDocument, avec `cascade: ['remove']` côté Doctrine
- Sérialisé dans le groupe `task:read` pour charger les documents avec le ticket
### Nettoyage des fichiers à la suppression
Quand un `TaskDocument` est supprimé (directement ou par cascade depuis Task), le fichier physique doit aussi être supprimé. Stratégie :
- **Doctrine EntityListener** (`TaskDocumentListener`) avec événement `preRemove`
- Récupère le `fileName` de l'entité et supprime le fichier de `var/uploads/documents/`
- Si le fichier n'existe pas sur disque (déjà supprimé manuellement), log un warning et continue sans erreur
Ceci couvre les deux cas :
1. Suppression directe d'un document via `DELETE /api/task_documents/{id}`
2. Suppression en cascade quand une Task est supprimée
### Stockage filesystem
- Répertoire : `var/uploads/documents/`
- Nommage : `{uuid}.{extension}` — évite les collisions et les caractères spéciaux
- Volume Docker dédié pour persister les uploads
- Ajouter `var/uploads/` dans `.gitignore`
### Téléchargement des fichiers
Endpoint dédié Symfony servi via un State Provider :
| Méthode | Route | Description | Accès |
|---------|-------|-------------|-------|
| `GET` | `/api/task_documents/{id}/download` | Télécharge le fichier (BinaryFileResponse) | ROLE_USER |
- Contrôle d'accès via authentification JWT (pas d'accès anonyme)
- Retourne le fichier avec les headers `Content-Disposition` (inline pour images/PDF, attachment pour les autres)
- Le frontend n'expose jamais le `fileName` interne dans l'URL — utilise l'`id` du document
### API Endpoints
| Méthode | Route | Description | Accès |
|---------|-------|-------------|-------|
| `POST` | `/api/task_documents` | Upload multipart/form-data | ROLE_ADMIN |
| `GET` | `/api/task_documents?task=/api/tasks/{id}` | Liste documents d'un ticket | ROLE_USER |
| `GET` | `/api/task_documents/{id}` | Métadonnées d'un document | ROLE_USER |
| `GET` | `/api/task_documents/{id}/download` | Télécharge le fichier | ROLE_USER |
| `DELETE` | `/api/task_documents/{id}` | Supprime document + fichier | ROLE_ADMIN |
### State Processor — POST (`TaskDocumentProcessor`)
1. Reçoit le fichier via multipart/form-data + IRI de la task
2. Valide : fichier non vide, taille ≤ 50 Mo
3. Génère un UUID v4, extrait l'extension du nom original
4. Déplace le fichier uploadé dans `var/uploads/documents/{uuid}.{ext}`
5. Si le déplacement du fichier échoue, throw une exception — ne pas persister l'entité
6. Crée et persiste l'entité `TaskDocument` avec toutes les métadonnées
7. Set `uploadedBy` depuis le token JWT courant
### State Processor — DELETE
1. Supprime l'entité de la base de données
2. Le nettoyage du fichier est géré automatiquement par le `TaskDocumentListener.preRemove`
### Validation
- Contrainte sur `originalName` : NotBlank
- Contrainte sur `task` : NotNull
- Validation dans le Processor : taille fichier ≤ 50 Mo, fichier présent dans la requête
- PHP `upload_max_filesize` et `post_max_size` à configurer ≥ 50 Mo
### Configuration PHP/Nginx
- `php.ini` : `upload_max_filesize = 50M`, `post_max_size = 55M`
- Nginx : `client_max_body_size 55m;`
## Frontend
### Placement dans l'UI
La zone de documents est placée **sous la description** dans le `TaskModal`, visible en mode édition.
### Composants à créer
Tous dans `frontend/components/task/` :
#### `TaskDocumentUpload.vue`
- Zone drag & drop avec bordure pointillée
- Texte : "Glisser des fichiers ici ou cliquer pour sélectionner" (clé i18n : `taskDocuments.dropzone`)
- Input file caché (`multiple`, `accept="*"`)
- Événements : `dragover`, `dragleave`, `drop` pour le feedback visuel
- Barre de progression par fichier pendant l'upload
- Upload **séquentiel** (un POST multipart par fichier, un à la fois) — plus simple et prévisible pour les progress bars
- Émet un événement quand l'upload est terminé pour rafraîchir la liste
#### `TaskDocumentList.vue`
- Grille de cartes compactes pour chaque document
- **Images** (`image/*`) : miniature 64x64 en `object-fit: cover`, chargée depuis l'URL de download
- Note : les images sont chargées en pleine résolution pour les miniatures. C'est une limitation acceptée — la génération de thumbnails côté serveur pourra être ajoutée ultérieurement si besoin.
- **Autres fichiers** : icône selon le type MIME :
- PDF → icône PDF
- Word/Excel → icônes Office
- Archives → icône archive
- Défaut → icône fichier générique
- Informations affichées : nom original (tronqué si > ~30 chars), taille formatée (Ko/Mo)
- Clic sur un document → ouvre `TaskDocumentPreview`
- Bouton supprimer (visible uniquement pour ROLE_ADMIN, avec confirmation)
#### `TaskDocumentPreview.vue`
- Modale plein écran (overlay sombre semi-transparent)
- Contenu selon le type :
- **Images** (`image/*`) : `<img>` centré, taille adaptative
- **PDF** (`application/pdf`) : `<iframe>` intégré
- **Autres** : grande icône + nom du fichier + taille + bouton "Télécharger"
- Navigation : flèches gauche/droite pour parcourir les documents du ticket
- Fermeture : bouton X en haut à droite, clic sur l'overlay, touche Escape
- Raccourcis clavier : flèches pour naviguer, Escape pour fermer
### Service API
`frontend/services/task-documents.ts` :
```typescript
getByTask(taskId: number): Promise<TaskDocument[]>
upload(taskId: number, file: File): Promise<TaskDocument>
remove(id: number): Promise<void>
getDownloadUrl(id: number): string // Retourne `/api/task_documents/{id}/download`
```
**Note upload :** la fonction `upload` ne peut pas utiliser `useApi().post()` directement car celui-ci set `Content-Type: application/json`. L'upload doit utiliser `$fetch` directement avec un `FormData` comme body et ne PAS setter de `Content-Type` (le navigateur le fait automatiquement avec le boundary multipart).
### DTO TypeScript
`frontend/services/dto/task-document.ts` :
```typescript
type TaskDocument = {
'@id'?: string
id: number
task: string // IRI
originalName: string
fileName: string
mimeType: string
size: number
createdAt: string
uploadedBy: string | null // IRI ou null si user supprimé
}
```
### Clés i18n
Ajouter dans `frontend/i18n/locales/` :
```
taskDocuments.dropzone → "Glisser des fichiers ici ou cliquer pour sélectionner"
taskDocuments.uploaded → "Document uploadé"
taskDocuments.deleted → "Document supprimé"
taskDocuments.uploadError → "Erreur lors de l'upload"
taskDocuments.confirmDelete → "Supprimer ce document ?"
taskDocuments.download → "Télécharger"
taskDocuments.documents → "Documents"
```
### Intégration dans TaskModal
- Import des 3 composants dans `TaskModal.vue`
- Sous le champ description :
1. `TaskDocumentUpload` (si mode édition, ROLE_ADMIN)
2. `TaskDocumentList` (toujours visible, passe les documents du ticket)
- `TaskDocumentPreview` monté conditionnellement (v-if sur document sélectionné)
- Chargement des documents : via la relation `task.documents` déjà sérialisée, ou appel séparé au service
## Migration
- Nouvelle table `task_document` avec les colonnes correspondant à l'entité
- Index sur `task_id` pour les requêtes filtrées
- Clé étrangère `task_id``task.id` ON DELETE CASCADE
- Clé étrangère `uploaded_by_id``user.id` ON DELETE SET NULL
## Docker
- Ajouter un volume nommé dans `docker-compose.yml` pour `var/uploads/` afin de persister les fichiers
- Le volume est monté dans le service PHP uniquement (pas besoin dans Nginx car les fichiers sont servis via Symfony)
- Vérifier la config PHP pour `upload_max_filesize` et `post_max_size`
## .gitignore
Ajouter `var/uploads/` dans `.gitignore` pour éviter de committer des fichiers uploadés en dev local.

View File

@@ -0,0 +1,116 @@
<template>
<div>
<h2 class="text-lg font-bold text-neutral-900">{{ $t('bookstack.settings.title') }}</h2>
<form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
<MalioInputText
v-model="form.url"
:label="$t('bookstack.settings.url')"
:placeholder="$t('bookstack.settings.urlPlaceholder')"
input-class="w-full"
/>
<div>
<MalioInputText
v-model="form.tokenId"
:label="$t('bookstack.settings.tokenId')"
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
input-class="w-full"
type="password"
/>
</div>
<div>
<MalioInputText
v-model="form.tokenSecret"
:label="$t('bookstack.settings.tokenSecret')"
:placeholder="$t('bookstack.settings.tokenSecretPlaceholder')"
input-class="w-full"
type="password"
/>
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
{{ $t('bookstack.settings.tokenConfigured') }}
</p>
</div>
<div class="flex gap-3">
<button
type="submit"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
:disabled="isSaving"
>
{{ $t('bookstack.settings.save') }}
</button>
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
:disabled="isTesting"
@click="handleTest"
>
{{ $t('bookstack.settings.testConnection') }}
</button>
</div>
<p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
{{ testResult ? $t('bookstack.settings.testSuccess') : $t('bookstack.settings.testFailed') }}
</p>
</form>
</div>
</template>
<script setup lang="ts">
import { useBookStackService } from '~/services/bookstack'
const { getSettings, saveSettings, testConnection } = useBookStackService()
const form = reactive({
url: '',
tokenId: '',
tokenSecret: '',
})
const hasToken = ref(false)
const isSaving = ref(false)
const isTesting = ref(false)
const testResult = ref<boolean | null>(null)
async function loadSettings() {
const settings = await getSettings()
form.url = settings.url ?? ''
hasToken.value = settings.hasToken
}
async function handleSave() {
isSaving.value = true
try {
const result = await saveSettings({
url: form.url.trim() || null,
tokenId: form.tokenId || null,
tokenSecret: form.tokenSecret || null,
})
hasToken.value = result.hasToken
form.tokenId = ''
form.tokenSecret = ''
testResult.value = null
} finally {
isSaving.value = false
}
}
async function handleTest() {
isTesting.value = true
testResult.value = null
try {
const result = await testConnection()
testResult.value = result.success
} catch {
testResult.value = false
} finally {
isTesting.value = false
}
}
onMounted(() => {
loadSettings()
})
</script>

View File

@@ -43,6 +43,16 @@
/> />
</div> </div>
<div v-if="bookstackShelves.length" class="mt-4">
<MalioSelect
v-model="form.bookstackShelfId"
:options="bookstackShelfOptions"
label="Étagère BookStack"
empty-option-label="Aucune étagère"
min-width="w-full"
/>
</div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<button <button
type="submit" type="submit"
@@ -71,8 +81,10 @@
import type { Project, ProjectWrite } from '~/services/dto/project' import type { Project, ProjectWrite } from '~/services/dto/project'
import type { Client } from '~/services/dto/client' import type { Client } from '~/services/dto/client'
import type { GiteaRepository } from '~/services/dto/gitea' import type { GiteaRepository } from '~/services/dto/gitea'
import type { BookStackShelf } from '~/services/dto/bookstack'
import { useProjectService } from '~/services/projects' import { useProjectService } from '~/services/projects'
import { useGiteaService } from '~/services/gitea' import { useGiteaService } from '~/services/gitea'
import { useBookStackService } from '~/services/bookstack'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -100,6 +112,13 @@ const giteaRepoOptions = computed(() =>
giteaRepos.value.map(r => ({ label: r.fullName, value: r.fullName })) giteaRepos.value.map(r => ({ label: r.fullName, value: r.fullName }))
) )
const { listShelves } = useBookStackService()
const bookstackShelves = ref<BookStackShelf[]>([])
const bookstackShelfOptions = computed(() =>
bookstackShelves.value.map(s => ({ label: s.name, value: s.id }))
)
const form = reactive({ const form = reactive({
code: '', code: '',
name: '', name: '',
@@ -107,6 +126,7 @@ const form = reactive({
color: '#222783', color: '#222783',
clientId: null as number | null, clientId: null as number | null,
giteaRepoFullName: null as string | null, giteaRepoFullName: null as string | null,
bookstackShelfId: null as number | null,
}) })
const touched = reactive({ const touched = reactive({
@@ -129,6 +149,7 @@ watch(() => props.modelValue, (open) => {
form.giteaRepoFullName = props.project?.giteaOwner && props.project?.giteaRepo form.giteaRepoFullName = props.project?.giteaOwner && props.project?.giteaRepo
? `${props.project.giteaOwner}/${props.project.giteaRepo}` ? `${props.project.giteaOwner}/${props.project.giteaRepo}`
: null : null
form.bookstackShelfId = props.project.bookstackShelfId ?? null
} else { } else {
form.code = '' form.code = ''
form.name = '' form.name = ''
@@ -136,6 +157,7 @@ watch(() => props.modelValue, (open) => {
form.color = '#222783' form.color = '#222783'
form.clientId = null form.clientId = null
form.giteaRepoFullName = null form.giteaRepoFullName = null
form.bookstackShelfId = null
} }
touched.code = false touched.code = false
touched.name = false touched.name = false
@@ -168,6 +190,15 @@ async function handleSubmit() {
payload.giteaRepo = null payload.giteaRepo = null
} }
if (form.bookstackShelfId) {
const shelf = bookstackShelves.value.find(s => s.id === form.bookstackShelfId)
payload.bookstackShelfId = form.bookstackShelfId
payload.bookstackShelfName = shelf?.name ?? null
} else {
payload.bookstackShelfId = null
payload.bookstackShelfName = null
}
if (isEditing.value && props.project) { if (isEditing.value && props.project) {
await update(props.project.id, payload) await update(props.project.id, payload)
} else { } else {
@@ -203,5 +234,10 @@ onMounted(async () => {
} catch { } catch {
// Gitea not configured, ignore // Gitea not configured, ignore
} }
try {
bookstackShelves.value = await listShelves()
} catch {
// BookStack not configured, ignore
}
}) })
</script> </script>

View File

@@ -0,0 +1,158 @@
<template>
<div class="mt-5">
<p class="mb-2 text-sm font-medium text-neutral-700">{{ $t('bookstack.links.title') }}</p>
<!-- Search -->
<div class="relative">
<MalioInputText
v-model="searchQuery"
:placeholder="$t('bookstack.links.searchPlaceholder')"
input-class="w-full"
/>
<!-- Dropdown results -->
<div
v-if="searchResults.length > 0"
class="absolute z-30 mt-1 w-full rounded-md border border-neutral-200 bg-white shadow-lg"
>
<button
v-for="result in searchResults"
:key="`${result.type}-${result.id}`"
type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-neutral-50"
@click="handleAdd(result)"
>
<Icon
:name="result.type === 'page' ? 'mdi:file-document-outline' : 'mdi:book-outline'"
size="16"
class="shrink-0 text-neutral-400"
/>
<span class="truncate">{{ result.name }}</span>
<span class="ml-auto shrink-0 text-xs text-neutral-400">{{ result.type }}</span>
</button>
</div>
<p v-if="searchQuery.length >= 2 && !isSearching && searchResults.length === 0 && hasSearched" class="mt-1 text-xs text-neutral-400">
{{ $t('bookstack.links.noResults') }}
</p>
</div>
<!-- Linked documents -->
<div v-if="links.length > 0" class="mt-3 space-y-1">
<div
v-for="link in links"
:key="link.id"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-neutral-50"
>
<Icon
:name="link.bookstackType === 'page' ? 'mdi:file-document-outline' : 'mdi:book-outline'"
size="16"
class="shrink-0 text-neutral-400"
/>
<a
:href="link.url"
target="_blank"
rel="noopener noreferrer"
class="truncate text-primary-500 hover:underline"
>
{{ link.title }}
</a>
<button
type="button"
class="ml-auto shrink-0 text-neutral-300 hover:text-red-500"
@click="handleRemove(link.id)"
>
<Icon name="mdi:close" size="16" />
</button>
</div>
</div>
<p v-else-if="!isLoading" class="mt-2 text-xs text-neutral-400">
{{ $t('bookstack.links.empty') }}
</p>
</div>
</template>
<script setup lang="ts">
import type { BookStackLink, BookStackSearchResult } from '~/services/dto/bookstack'
import { useBookStackService } from '~/services/bookstack'
const props = defineProps<{
taskId: number
}>()
const { getLinks, addLink, removeLink, search } = useBookStackService()
const links = ref<BookStackLink[]>([])
const searchQuery = ref('')
const searchResults = ref<BookStackSearchResult[]>([])
const isLoading = ref(true)
const isSearching = ref(false)
const hasSearched = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(searchQuery, (query) => {
if (debounceTimer) clearTimeout(debounceTimer)
hasSearched.value = false
searchResults.value = []
if (query.trim().length < 2) {
return
}
debounceTimer = setTimeout(async () => {
isSearching.value = true
try {
searchResults.value = await search(props.taskId, query.trim())
} catch {
searchResults.value = []
} finally {
isSearching.value = false
hasSearched.value = true
}
}, 300)
})
async function handleAdd(result: BookStackSearchResult) {
searchQuery.value = ''
searchResults.value = []
hasSearched.value = false
// Check if already linked
if (links.value.some(l => l.bookstackId === result.id && l.bookstackType === result.type)) {
return
}
try {
const created = await addLink(props.taskId, {
bookstackId: result.id,
bookstackType: result.type,
title: result.name,
url: result.url,
})
links.value.unshift(created)
} catch {
// Error handled by useApi toast
}
}
async function handleRemove(linkId: number) {
try {
await removeLink(props.taskId, linkId)
links.value = links.value.filter(l => l.id !== linkId)
} catch {
// Error handled by useApi toast
}
}
onMounted(async () => {
try {
links.value = await getLinks(props.taskId)
} catch {
// Error handled by useApi toast
} finally {
isLoading.value = false
}
})
</script>

View File

@@ -0,0 +1,80 @@
<template>
<div v-if="documents.length" class="mt-3">
<p class="mb-2 text-sm font-medium text-neutral-700">
{{ $t('taskDocuments.title') }} ({{ documents.length }})
</p>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<div
v-for="doc in documents"
:key="doc.id"
class="group relative flex cursor-pointer items-center gap-2 rounded-lg border border-neutral-200 p-2 transition-colors hover:bg-neutral-50"
@click="$emit('preview', doc)"
>
<!-- Thumbnail or icon -->
<div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded">
<img
v-if="isImage(doc.mimeType)"
:src="getDownloadUrl(doc.id)"
:alt="doc.originalName"
class="h-10 w-10 object-cover"
/>
<Icon
v-else
:name="getIconForMime(doc.mimeType)"
class="h-6 w-6 text-neutral-400"
/>
</div>
<!-- File info -->
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p>
<p class="text-xs text-neutral-400">{{ formatSize(doc.size) }}</p>
</div>
<!-- Delete button -->
<button
v-if="isAdmin"
class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block"
@click.stop="$emit('delete', doc)"
>
<Icon name="heroicons:x-mark" class="h-4 w-4" />
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
defineProps<{
documents: TaskDocument[]
isAdmin: boolean
}>()
defineEmits<{
preview: [doc: TaskDocument]
delete: [doc: TaskDocument]
}>()
const { getDownloadUrl } = useTaskDocumentService()
function isImage(mimeType: string): boolean {
return mimeType.startsWith('image/')
}
function getIconForMime(mimeType: string): string {
if (mimeType === 'application/pdf') return 'heroicons:document-text'
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'heroicons:table-cells'
if (mimeType.includes('word') || mimeType.includes('document')) return 'heroicons:document'
if (mimeType.includes('zip') || mimeType.includes('archive') || mimeType.includes('tar') || mimeType.includes('rar')) return 'heroicons:archive-box'
return 'heroicons:paper-clip'
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
}
</script>

View File

@@ -0,0 +1,124 @@
<template>
<Teleport to="body">
<Transition name="fade" appear>
<div
v-if="document"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80"
@click.self="$emit('close')"
@keydown.escape="$emit('close')"
@keydown.left="$emit('prev')"
@keydown.right="$emit('next')"
tabindex="0"
ref="overlayRef"
>
<!-- Close button -->
<button
class="absolute right-4 top-4 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
@click="$emit('close')"
>
<Icon name="heroicons:x-mark" class="h-6 w-6" />
</button>
<!-- Navigation arrows -->
<button
v-if="hasPrev"
class="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
@click="$emit('prev')"
>
<Icon name="heroicons:chevron-left" class="h-6 w-6" />
</button>
<button
v-if="hasNext"
class="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
@click="$emit('next')"
>
<Icon name="heroicons:chevron-right" class="h-6 w-6" />
</button>
<!-- Content -->
<div class="flex max-h-[90vh] max-w-[90vw] flex-col items-center">
<!-- Image preview -->
<img
v-if="isImage"
:src="downloadUrl"
:alt="document.originalName"
class="max-h-[85vh] max-w-[90vw] object-contain"
/>
<!-- PDF preview -->
<iframe
v-else-if="isPdf"
:src="downloadUrl"
class="h-[85vh] w-[80vw] rounded-lg bg-white"
/>
<!-- Generic file -->
<div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
<Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
<p class="max-w-xs truncate text-lg font-medium text-neutral-700">{{ document.originalName }}</p>
<p class="text-sm text-neutral-400">{{ formatSize(document.size) }}</p>
<a
:href="downloadUrl"
download
class="mt-2 rounded-lg bg-blue-600 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
>
{{ $t('taskDocuments.download') }}
</a>
</div>
<!-- File name footer -->
<p class="mt-3 text-sm text-white/70">{{ document.originalName }}</p>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
const props = defineProps<{
document: TaskDocument | null
hasPrev: boolean
hasNext: boolean
}>()
defineEmits<{
close: []
prev: []
next: []
}>()
const overlayRef = ref<HTMLElement | null>(null)
const { getDownloadUrl } = useTaskDocumentService()
const downloadUrl = computed(() => props.document ? getDownloadUrl(props.document.id) : '')
const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false)
const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
}
// Focus overlay for keyboard events
watch(() => props.document, (doc) => {
if (doc) {
nextTick(() => overlayRef.value?.focus())
}
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div
class="relative mt-4 rounded-lg border-2 border-dashed transition-colors"
:class="isDragging ? 'border-blue-400 bg-blue-50' : 'border-neutral-300 hover:border-neutral-400'"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
@click="fileInput?.click()"
>
<input
ref="fileInput"
type="file"
multiple
class="hidden"
@change="handleFileSelect"
/>
<div class="flex cursor-pointer flex-col items-center gap-2 px-4 py-6 text-center">
<Icon name="heroicons:cloud-arrow-up" class="h-8 w-8 text-neutral-400" />
<p class="text-sm text-neutral-500">{{ $t('taskDocuments.dropzone') }}</p>
</div>
<!-- Upload progress -->
<div v-if="uploads.length" class="space-y-2 border-t border-neutral-200 px-4 py-3">
<div v-for="upload in uploads" :key="upload.name" class="flex items-center gap-3">
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-neutral-700">{{ upload.name }}</p>
<div class="mt-1 h-1.5 w-full overflow-hidden rounded-full bg-neutral-200">
<div
class="h-full rounded-full transition-all"
:class="[
upload.error ? 'bg-red-500' : upload.uploading ? 'animate-pulse bg-blue-400' : 'bg-green-500',
]"
:style="{ width: upload.uploading ? '70%' : `${upload.progress}%` }"
/>
</div>
</div>
<Icon
v-if="upload.error"
name="heroicons:exclamation-circle"
class="h-5 w-5 shrink-0 text-red-500"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useTaskDocumentService } from '~/services/task-documents'
const props = defineProps<{
taskId: number
}>()
const emit = defineEmits<{
uploaded: []
}>()
const { upload: uploadFile } = useTaskDocumentService()
const toast = useToast()
const { t } = useI18n()
const fileInput = ref<HTMLInputElement | null>(null)
const isDragging = ref(false)
type UploadState = {
name: string
progress: number
uploading: boolean
error: boolean
}
const uploads = ref<UploadState[]>([])
function handleDrop(event: DragEvent) {
isDragging.value = false
const files = event.dataTransfer?.files
if (files?.length) {
processFiles(Array.from(files))
}
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement
if (input.files?.length) {
processFiles(Array.from(input.files))
input.value = ''
}
}
async function processFiles(files: File[]) {
const maxSize = 50 * 1024 * 1024
for (const file of files) {
if (file.size > maxSize) {
toast.error({
title: 'Erreur',
message: t('taskDocuments.maxSizeError'),
})
continue
}
const state: UploadState = reactive({
name: file.name,
progress: 30,
uploading: true,
error: false,
})
uploads.value.push(state)
try {
await uploadFile(props.taskId, file)
state.uploading = false
state.progress = 100
} catch {
state.uploading = false
state.error = true
state.progress = 100
toast.error({
title: 'Erreur',
message: t('taskDocuments.uploadError'),
})
}
emit('uploaded')
}
// Clean up completed uploads after a delay
setTimeout(() => {
uploads.value = uploads.value.filter(u => u.error)
}, 1500)
}
</script>

View File

@@ -61,7 +61,7 @@
<!-- Error state --> <!-- Error state -->
<div v-if="error" class="px-4 py-3"> <div v-if="error" class="px-4 py-3">
<p class="text-xs text-red-500">{{ $t('gitea.error') }}</p> <p class="text-xs text-red-500">{{ error }}</p>
</div> </div>
<!-- Create branch form (inline) --> <!-- Create branch form (inline) -->
@@ -248,7 +248,7 @@ const pullRequests = ref<GiteaPullRequest[]>([])
const isLoading = ref(true) const isLoading = ref(true)
const isLoadingPrs = ref(true) const isLoadingPrs = ref(true)
const isCreating = ref(false) const isCreating = ref(false)
const error = ref(false) const error = ref('')
const showCreateForm = ref(false) const showCreateForm = ref(false)
const expandedBranches = ref(new Set<string>()) const expandedBranches = ref(new Set<string>())
@@ -338,7 +338,7 @@ async function loadData() {
isLoading.value = true isLoading.value = true
isLoadingPrs.value = true isLoadingPrs.value = true
error.value = false error.value = ''
try { try {
branches.value = await listBranches(props.task.id) branches.value = await listBranches(props.task.id)
@@ -346,8 +346,8 @@ async function loadData() {
if (branches.value.length === 1) { if (branches.value.length === 1) {
expandedBranches.value.add(branches.value[0].name) expandedBranches.value.add(branches.value[0].name)
} }
} catch { } catch (e: any) {
error.value = true error.value = e?.data?.detail || e?.data?.['hydra:description'] || t('gitea.error')
} finally { } finally {
isLoading.value = false isLoading.value = false
} }

View File

@@ -121,6 +121,30 @@
/> />
</div> </div>
<!-- Documents -->
<TaskDocumentUpload
v-if="isEditing && task && isAdmin"
:task-id="task.id"
@uploaded="handleDocumentUploaded"
/>
<TaskDocumentList
v-if="isEditing && task"
:documents="documents"
:is-admin="isAdmin"
@preview="openPreview"
@delete="handleDeleteDocument"
/>
<!-- Document preview modal -->
<TaskDocumentPreview
:document="previewDoc"
:has-prev="previewIndex > 0"
:has-next="previewIndex < documents.length - 1"
@close="previewDoc = null"
@prev="prevPreview"
@next="nextPreview"
/>
<!-- Git section --> <!-- Git section -->
<TaskGitSection <TaskGitSection
v-if="hasGitea && isEditing && task" v-if="hasGitea && isEditing && task"
@@ -128,6 +152,12 @@
:gitea-url="giteaUrl" :gitea-url="giteaUrl"
/> />
<!-- BookStack links -->
<TaskBookStackLinks
v-if="hasBookStack && isEditing && task"
:task-id="task.id"
/>
<!-- Footer --> <!-- Footer -->
<div <div
class="mt-6 flex items-center border-t border-neutral-100 pt-5" class="mt-6 flex items-center border-t border-neutral-100 pt-5"
@@ -183,6 +213,12 @@
v-model="confirmDeleteOpen" v-model="confirmDeleteOpen"
@confirm="handleDelete" @confirm="handleDelete"
/> />
<!-- Confirm delete document modal -->
<ConfirmDeleteDocumentModal
v-model="confirmDeleteDocOpen"
@confirm="confirmDeleteDocument"
/>
</div> </div>
</div> </div>
</Transition> </Transition>
@@ -191,7 +227,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Task, TaskWrite } from '~/services/dto/task' import type { Task, TaskWrite } from '~/services/dto/task'
import type { TaskDocument } from '~/services/dto/task-document'
import { useGiteaService } from '~/services/gitea' import { useGiteaService } from '~/services/gitea'
import { useTaskDocumentService } from '~/services/task-documents'
import ConfirmDeleteDocumentModal from '~/components/ui/ConfirmDeleteDocumentModal.vue'
import type { TaskStatus } from '~/services/dto/task-status' import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskEffort } from '~/services/dto/task-effort' import type { TaskEffort } from '~/services/dto/task-effort'
import type { TaskPriority } from '~/services/dto/task-priority' import type { TaskPriority } from '~/services/dto/task-priority'
@@ -237,6 +276,10 @@ const hasGitea = computed(() => {
return !!props.task?.project?.giteaOwner && !!props.task?.project?.giteaRepo && !!giteaUrl.value return !!props.task?.project?.giteaOwner && !!props.task?.project?.giteaRepo && !!giteaUrl.value
}) })
const hasBookStack = computed(() => {
return !!props.task?.project?.bookstackShelfId
})
const form = reactive({ const form = reactive({
title: '', title: '',
description: '', description: '',
@@ -339,6 +382,66 @@ watch(() => props.modelValue, async (open) => {
}) })
const { create, update, remove } = useTaskService() const { create, update, remove } = useTaskService()
const { remove: removeDocument, getByTask: getDocumentsByTask } = useTaskDocumentService()
const { t } = useI18n()
const authStore = useAuthStore()
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
const localDocuments = ref<TaskDocument[]>([])
const documents = computed(() => localDocuments.value)
const previewDoc = ref<TaskDocument | null>(null)
// Sync documents from task prop when modal opens or task changes
watch(() => props.task?.documents, (docs) => {
localDocuments.value = docs ? [...docs] : []
}, { immediate: true })
async function refreshDocuments() {
if (!props.task) return
localDocuments.value = await getDocumentsByTask(props.task.id)
}
const previewIndex = computed(() => {
if (!previewDoc.value) return -1
return documents.value.findIndex(d => d.id === previewDoc.value!.id)
})
function openPreview(doc: TaskDocument) {
previewDoc.value = doc
}
function prevPreview() {
if (previewIndex.value > 0) {
previewDoc.value = documents.value[previewIndex.value - 1]
}
}
function nextPreview() {
if (previewIndex.value < documents.value.length - 1) {
previewDoc.value = documents.value[previewIndex.value + 1]
}
}
const confirmDeleteDocOpen = ref(false)
const documentToDelete = ref<TaskDocument | null>(null)
function handleDeleteDocument(doc: TaskDocument) {
documentToDelete.value = doc
confirmDeleteDocOpen.value = true
}
async function confirmDeleteDocument() {
if (!documentToDelete.value) return
await removeDocument(documentToDelete.value.id)
confirmDeleteDocOpen.value = false
documentToDelete.value = null
await refreshDocuments()
}
async function handleDocumentUploaded() {
await refreshDocuments()
}
async function handleDelete() { async function handleDelete() {
if (!props.task) return if (!props.task) return

View File

@@ -0,0 +1,58 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-[70] 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('taskDocuments.confirmDeleteTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('taskDocuments.confirmDeleteMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50"
@click="cancel"
>
{{ $t('common.cancel') }}
</button>
<button
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700"
@click="$emit('confirm')"
>
Supprimer
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
emit('update:modelValue', false)
}
</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

@@ -60,6 +60,17 @@
"archived": "Groupe archivé avec succès.", "archived": "Groupe archivé avec succès.",
"unarchived": "Groupe désarchivé avec succès." "unarchived": "Groupe désarchivé avec succès."
}, },
"taskDocuments": {
"title": "Documents",
"dropzone": "Glisser des fichiers ici ou cliquer pour sélectionner",
"uploaded": "Document ajouté avec succès.",
"deleted": "Document supprimé avec succès.",
"uploadError": "Erreur lors de l'upload du document.",
"confirmDeleteTitle": "Supprimer le document",
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce document ?",
"download": "Télécharger",
"maxSizeError": "Le fichier dépasse la taille maximale de 50 Mo."
},
"tasks": { "tasks": {
"created": "Ticket créé avec succès.", "created": "Ticket créé avec succès.",
"updated": "Ticket mis à jour avec succès.", "updated": "Ticket mis à jour avec succès.",
@@ -201,5 +212,28 @@
}, },
"error": "Erreur de connexion à Gitea.", "error": "Erreur de connexion à Gitea.",
"notConfigured": "Gitea non configuré pour ce projet." "notConfigured": "Gitea non configuré pour ce projet."
},
"bookstack": {
"settings": {
"title": "Configuration BookStack",
"url": "URL du serveur",
"urlPlaceholder": "https://wiki.example.com",
"tokenId": "Token ID",
"tokenIdPlaceholder": "Entrez le Token ID",
"tokenSecret": "Token Secret",
"tokenSecretPlaceholder": "Entrez le Token Secret",
"tokenConfigured": "Token configuré",
"save": "Enregistrer",
"saved": "Configuration BookStack sauvegardée.",
"testConnection": "Tester la connexion",
"testSuccess": "Connexion réussie.",
"testFailed": "Connexion échouée."
},
"links": {
"title": "Documentation",
"searchPlaceholder": "Rechercher une page ou un livre...",
"noResults": "Aucun résultat",
"empty": "Aucun document lié"
}
} }
} }

View File

@@ -28,6 +28,7 @@
<AdminTagTab v-if="activeTab === 'tags'" /> <AdminTagTab v-if="activeTab === 'tags'" />
<AdminUserTab v-if="activeTab === 'users'" /> <AdminUserTab v-if="activeTab === 'users'" />
<AdminGiteaTab v-if="activeTab === 'gitea'" /> <AdminGiteaTab v-if="activeTab === 'gitea'" />
<AdminBookStackTab v-if="activeTab === 'bookstack'" />
</div> </div>
</div> </div>
</template> </template>
@@ -43,6 +44,7 @@ const tabs = [
{ key: 'tags', label: 'Tags' }, { key: 'tags', label: 'Tags' },
{ key: 'users', label: 'Utilisateurs' }, { key: 'users', label: 'Utilisateurs' },
{ key: 'gitea', label: 'Gitea' }, { key: 'gitea', label: 'Gitea' },
{ key: 'bookstack', label: 'BookStack' },
] as const ] as const
type TabKey = typeof tabs[number]['key'] type TabKey = typeof tabs[number]['key']

View File

@@ -13,11 +13,11 @@
</div> </div>
<div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4"> <div class="relative z-30 mt-4 flex flex-wrap items-center gap-3 sm:gap-4">
<h2 class="text-lg font-bold text-orange-500"> <h2 class="shrink-0 whitespace-nowrap text-lg font-bold text-orange-500">
{{ currentMonthLabel }} {{ currentMonthLabel }}
</h2> </h2>
<div class="flex items-center gap-1 rounded-md border border-neutral-200"> <div class="flex shrink-0 items-center gap-1 rounded-md border border-neutral-200">
<button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigatePrev"> <button class="px-2 py-1 text-neutral-500 hover:text-neutral-700" @click="navigatePrev">
<Icon name="mdi:chevron-left" size="20" /> <Icon name="mdi:chevron-left" size="20" />
</button> </button>
@@ -35,35 +35,41 @@
</button> </button>
</div> </div>
<MalioSelect <div class="[&>div]:!mt-0">
v-model="selectedUserId" <MalioSelect
:options="userOptions" v-model="selectedUserId"
min-width="!w-36 sm:!w-44" :options="userOptions"
text-field="text-sm" min-width="!w-36 sm:!w-44"
text-value="text-sm" text-field="text-sm"
label="User" text-value="text-sm"
empty-option-label="User" label="User"
/> empty-option-label="User"
/>
</div>
<MalioSelect <div class="[&>div]:!mt-0">
v-model="selectedProjectId" <MalioSelect
:options="projectOptions" v-model="selectedProjectId"
empty-option-label="Tous" :options="projectOptions"
label="Projet" empty-option-label="Tous"
min-width="!w-36 sm:!w-44" label="Projet"
text-field="text-sm" min-width="!w-36 sm:!w-44"
text-value="text-sm" text-field="text-sm"
/> text-value="text-sm"
/>
</div>
<MalioSelect <div class="[&>div]:!mt-0">
v-model="selectedTagId" <MalioSelect
:options="tagOptions" v-model="selectedTagId"
empty-option-label="Tous" :options="tagOptions"
label="Tag" empty-option-label="Tous"
min-width="!w-36 sm:!w-44" label="Tag"
text-field="text-sm" min-width="!w-36 sm:!w-44"
text-value="text-sm" text-field="text-sm"
/> text-value="text-sm"
/>
</div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,66 @@
import type {
BookStackSettings,
BookStackSettingsWrite,
BookStackTestResult,
BookStackShelf,
BookStackLink,
BookStackLinkCreate,
BookStackSearchResult,
} from './dto/bookstack'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function useBookStackService() {
const api = useApi()
async function getSettings(): Promise<BookStackSettings> {
return api.get<BookStackSettings>('/settings/bookstack')
}
async function saveSettings(payload: BookStackSettingsWrite): Promise<BookStackSettings> {
return api.put<BookStackSettings>('/settings/bookstack', payload as Record<string, unknown>, {
toastSuccessKey: 'bookstack.settings.saved',
})
}
async function testConnection(): Promise<BookStackTestResult> {
return api.post<BookStackTestResult>('/settings/bookstack/test')
}
async function listShelves(): Promise<BookStackShelf[]> {
const data = await api.get<HydraCollection<BookStackShelf>>('/bookstack/shelves')
return extractHydraMembers(data)
}
async function getLinks(taskId: number): Promise<BookStackLink[]> {
const data = await api.get<HydraCollection<BookStackLink>>(`/tasks/${taskId}/bookstack/links`)
return extractHydraMembers(data)
}
async function addLink(taskId: number, payload: BookStackLinkCreate): Promise<BookStackLink> {
return api.post<BookStackLink>(`/tasks/${taskId}/bookstack/links`, payload as Record<string, unknown>)
}
async function removeLink(taskId: number, linkId: number): Promise<void> {
await api.delete(`/tasks/${taskId}/bookstack/links/${linkId}`)
}
async function search(taskId: number, query: string): Promise<BookStackSearchResult[]> {
const data = await api.get<HydraCollection<BookStackSearchResult>>(
`/tasks/${taskId}/bookstack/search`,
{ q: query },
)
return extractHydraMembers(data)
}
return {
getSettings,
saveSettings,
testConnection,
listShelves,
getLinks,
addLink,
removeLink,
search,
}
}

View File

@@ -0,0 +1,42 @@
export type BookStackSettings = {
url: string | null
hasToken: boolean
}
export type BookStackSettingsWrite = {
url: string | null
tokenId: string | null
tokenSecret: string | null
}
export type BookStackTestResult = {
success: boolean
}
export type BookStackShelf = {
id: number
name: string
}
export type BookStackLink = {
id: number
bookstackId: number
bookstackType: 'page' | 'book'
title: string
url: string
createdAt: string
}
export type BookStackLinkCreate = {
bookstackId: number
bookstackType: 'page' | 'book'
title: string
url: string
}
export type BookStackSearchResult = {
id: number
type: 'page' | 'book'
name: string
url: string
}

View File

@@ -10,6 +10,8 @@ export type Project = {
client: Client | null client: Client | null
giteaOwner: string | null giteaOwner: string | null
giteaRepo: string | null giteaRepo: string | null
bookstackShelfId: number | null
bookstackShelfName: string | null
archived: boolean archived: boolean
} }
@@ -21,5 +23,7 @@ export type ProjectWrite = {
client: string | null // IRI : "/api/clients/1" ou null client: string | null // IRI : "/api/clients/1" ou null
giteaOwner?: string | null giteaOwner?: string | null
giteaRepo?: string | null giteaRepo?: string | null
bookstackShelfId?: number | null
bookstackShelfName?: string | null
archived?: boolean archived?: boolean
} }

View File

@@ -0,0 +1,13 @@
import type { UserData } from './user-data'
export type TaskDocument = {
'@id'?: string
id: number
task: string
originalName: string
fileName: string
mimeType: string
size: number
createdAt: string
uploadedBy: UserData | null
}

View File

@@ -5,6 +5,7 @@ import type { TaskTag } from './task-tag'
import type { TaskGroup } from './task-group' import type { TaskGroup } from './task-group'
import type { UserData } from './user-data' import type { UserData } from './user-data'
import type { Project } from './project' import type { Project } from './project'
import type { TaskDocument } from './task-document'
export type Task = { export type Task = {
id: number id: number
@@ -19,6 +20,7 @@ export type Task = {
group: TaskGroup | null group: TaskGroup | null
project: Project | null project: Project | null
tags: TaskTag[] tags: TaskTag[]
documents: TaskDocument[]
archived: boolean archived: boolean
} }

View File

@@ -0,0 +1,42 @@
import type { TaskDocument } from './dto/task-document'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
import { $fetch } from 'ofetch'
export function useTaskDocumentService() {
const api = useApi()
const config = useRuntimeConfig()
const baseURL = config.public.apiBase || '/api'
async function getByTask(taskId: number): Promise<TaskDocument[]> {
const data = await api.get<HydraCollection<TaskDocument>>('/task_documents', {
task: `/api/tasks/${taskId}`,
})
return extractHydraMembers(data)
}
async function upload(taskId: number, file: File): Promise<TaskDocument> {
const formData = new FormData()
formData.append('file', file)
formData.append('task', `/api/tasks/${taskId}`)
return await $fetch<TaskDocument>(`${baseURL}/task_documents`, {
method: 'POST',
body: formData,
credentials: 'include',
// Do NOT set Content-Type — browser sets multipart boundary automatically
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/task_documents/${id}`, {}, {
toastSuccessKey: 'taskDocuments.deleted',
})
}
function getDownloadUrl(id: number): string {
return `${baseURL}/task_documents/${id}/download`
}
return { getByTask, upload, remove, getDownloadUrl }
}

View File

@@ -0,0 +1,39 @@
<?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 Version20260315170358 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE task_document (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, original_name VARCHAR(255) NOT NULL, file_name VARCHAR(255) NOT NULL, mime_type VARCHAR(100) NOT NULL, size INT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, task_id INT NOT NULL, uploaded_by_id INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_98A9603A8DB60186 ON task_document (task_id)');
$this->addSql('CREATE INDEX IDX_98A9603AA2B28FE8 ON task_document (uploaded_by_id)');
$this->addSql('ALTER TABLE task_document ADD CONSTRAINT FK_98A9603A8DB60186 FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE task_document ADD CONSTRAINT FK_98A9603AA2B28FE8 FOREIGN KEY (uploaded_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE project ALTER archived DROP DEFAULT');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE task_document DROP CONSTRAINT FK_98A9603A8DB60186');
$this->addSql('ALTER TABLE task_document DROP CONSTRAINT FK_98A9603AA2B28FE8');
$this->addSql('DROP TABLE task_document');
$this->addSql('ALTER TABLE project ALTER archived SET DEFAULT false');
}
}

View File

@@ -0,0 +1,42 @@
<?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 Version20260315170552 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE book_stack_configuration (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, url VARCHAR(255) DEFAULT NULL, encrypted_token_id TEXT DEFAULT NULL, encrypted_token_secret TEXT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE TABLE task_book_stack_link (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, bookstack_id INT NOT NULL, bookstack_type VARCHAR(10) NOT NULL, title VARCHAR(255) NOT NULL, url VARCHAR(500) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, task_id INT NOT NULL, PRIMARY KEY (id))');
$this->addSql('COMMENT ON COLUMN task_book_stack_link.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE INDEX IDX_E3E40EBB8DB60186 ON task_book_stack_link (task_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_task_bookstack_link ON task_book_stack_link (task_id, bookstack_id, bookstack_type)');
$this->addSql('ALTER TABLE task_book_stack_link ADD CONSTRAINT FK_E3E40EBB8DB60186 FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE project ADD bookstack_shelf_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE project ADD bookstack_shelf_name VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE task_book_stack_link DROP CONSTRAINT FK_E3E40EBB8DB60186');
$this->addSql('DROP TABLE book_stack_configuration');
$this->addSql('DROP TABLE task_book_stack_link');
$this->addSql('ALTER TABLE project DROP bookstack_shelf_id');
$this->addSql('ALTER TABLE project DROP bookstack_shelf_name');
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Post;
use App\Entity\Task;
use App\Entity\TaskBookStackLink;
use App\State\BookStackLinkProcessor;
use App\State\BookStackLinkProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/tasks/{taskId}/bookstack/links',
uriVariables: [
'taskId' => new Link(fromClass: Task::class, identifiers: ['id']),
],
normalizationContext: ['groups' => ['bookstack_link:read']],
provider: BookStackLinkProvider::class,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
new Post(
uriTemplate: '/tasks/{taskId}/bookstack/links',
uriVariables: [
'taskId' => new Link(fromClass: Task::class, identifiers: ['id']),
],
denormalizationContext: ['groups' => ['bookstack_link:write']],
normalizationContext: ['groups' => ['bookstack_link:read']],
provider: BookStackLinkProvider::class,
processor: BookStackLinkProcessor::class,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
new Delete(
uriTemplate: '/tasks/{taskId}/bookstack/links/{id}',
uriVariables: [
'taskId' => new Link(fromClass: Task::class, identifiers: ['id']),
'id' => new Link(fromClass: TaskBookStackLink::class, identifiers: ['id']),
],
provider: BookStackLinkProvider::class,
processor: BookStackLinkProcessor::class,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
],
)]
final class BookStackLink
{
#[Groups(['bookstack_link:read'])]
public ?int $id = null;
#[Groups(['bookstack_link:read', 'bookstack_link:write'])]
public int $bookstackId = 0;
#[Groups(['bookstack_link:read', 'bookstack_link:write'])]
public string $bookstackType = '';
#[Groups(['bookstack_link:read', 'bookstack_link:write'])]
public string $title = '';
#[Groups(['bookstack_link:read', 'bookstack_link:write'])]
public string $url = '';
#[Groups(['bookstack_link:read'])]
public ?string $createdAt = null;
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use App\Entity\Task;
use App\State\BookStackSearchResultProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/tasks/{taskId}/bookstack/search',
uriVariables: [
'taskId' => new Link(fromClass: Task::class, identifiers: ['id']),
],
normalizationContext: ['groups' => ['bookstack_search:read']],
provider: BookStackSearchResultProvider::class,
security: "is_granted('IS_AUTHENTICATED_FULLY')",
),
],
)]
final class BookStackSearchResult
{
#[Groups(['bookstack_search:read'])]
public int $id = 0;
#[Groups(['bookstack_search:read'])]
public string $type = '';
#[Groups(['bookstack_search:read'])]
public string $name = '';
#[Groups(['bookstack_search:read'])]
public string $url = '';
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use App\State\BookStackSettingsProcessor;
use App\State\BookStackSettingsProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/settings/bookstack',
normalizationContext: ['groups' => ['bookstack_settings:read']],
provider: BookStackSettingsProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
new Put(
uriTemplate: '/settings/bookstack',
denormalizationContext: ['groups' => ['bookstack_settings:write']],
normalizationContext: ['groups' => ['bookstack_settings:read']],
provider: BookStackSettingsProvider::class,
processor: BookStackSettingsProcessor::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class BookStackSettings
{
#[Groups(['bookstack_settings:read', 'bookstack_settings:write'])]
public ?string $url = null;
#[Groups(['bookstack_settings:write'])]
public ?string $tokenId = null;
#[Groups(['bookstack_settings:write'])]
public ?string $tokenSecret = null;
#[Groups(['bookstack_settings:read'])]
public bool $hasToken = false;
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\State\BookStackShelfProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/bookstack/shelves',
normalizationContext: ['groups' => ['bookstack_shelf:read']],
provider: BookStackShelfProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class BookStackShelf
{
#[Groups(['bookstack_shelf:read'])]
public int $id = 0;
#[Groups(['bookstack_shelf:read'])]
public string $name = '';
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\BookStackTestConnectionProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/settings/bookstack/test',
input: false,
normalizationContext: ['groups' => ['bookstack_test:read']],
provider: BookStackTestConnectionProvider::class,
processor: BookStackTestConnectionProvider::class,
security: "is_granted('ROLE_ADMIN')",
),
],
)]
final class BookStackTestConnection
{
#[Groups(['bookstack_test:read'])]
public bool $success = false;
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\TaskDocument;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class TaskDocumentDownloadController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly string $uploadDir,
) {}
#[Route('/api/task_documents/{id}/download', name: 'task_document_download', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function __invoke(int $id): BinaryFileResponse
{
$document = $this->entityManager->getRepository(TaskDocument::class)->find($id);
if (null === $document) {
throw new NotFoundHttpException('Document not found.');
}
$filePath = $this->uploadDir.'/'.$document->getFileName();
if (!file_exists($filePath)) {
throw new NotFoundHttpException('File not found on disk.');
}
$response = new BinaryFileResponse($filePath);
$mimeType = $document->getMimeType() ?? 'application/octet-stream';
// Inline for images and PDFs, attachment for everything else
$disposition = str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType
? ResponseHeaderBag::DISPOSITION_INLINE
: ResponseHeaderBag::DISPOSITION_ATTACHMENT;
$response->setContentDisposition($disposition, $document->getOriginalName());
$response->headers->set('Content-Type', $mimeType);
return $response;
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\BookStackConfigurationRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: BookStackConfigurationRepository::class)]
class BookStackConfiguration
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $url = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $encryptedTokenId = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $encryptedTokenSecret = null;
public function getId(): ?int
{
return $this->id;
}
public function getUrl(): ?string
{
return $this->url;
}
public function setUrl(?string $url): static
{
$this->url = $url;
return $this;
}
public function getEncryptedTokenId(): ?string
{
return $this->encryptedTokenId;
}
public function setEncryptedTokenId(?string $encryptedTokenId): static
{
$this->encryptedTokenId = $encryptedTokenId;
return $this;
}
public function getEncryptedTokenSecret(): ?string
{
return $this->encryptedTokenSecret;
}
public function setEncryptedTokenSecret(?string $encryptedTokenSecret): static
{
$this->encryptedTokenSecret = $encryptedTokenSecret;
return $this;
}
public function hasToken(): bool
{
return null !== $this->encryptedTokenId && null !== $this->encryptedTokenSecret;
}
}

View File

@@ -75,6 +75,14 @@ class Project
#[Groups(['project:read', 'project:write', 'task:read'])] #[Groups(['project:read', 'project:write', 'task:read'])]
private ?string $giteaRepo = null; private ?string $giteaRepo = null;
#[ORM\Column(nullable: true)]
#[Groups(['project:read', 'project:write', 'task:read'])]
private ?int $bookstackShelfId = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['project:read', 'project:write'])]
private ?string $bookstackShelfName = null;
#[ORM\Column] #[ORM\Column]
#[Groups(['project:read', 'project:write'])] #[Groups(['project:read', 'project:write'])]
private bool $archived = false; private bool $archived = false;
@@ -184,4 +192,28 @@ class Project
return $this; return $this;
} }
public function getBookstackShelfId(): ?int
{
return $this->bookstackShelfId;
}
public function setBookstackShelfId(?int $bookstackShelfId): static
{
$this->bookstackShelfId = $bookstackShelfId;
return $this;
}
public function getBookstackShelfName(): ?string
{
return $this->bookstackShelfName;
}
public function setBookstackShelfName(?string $bookstackShelfName): static
{
$this->bookstackShelfName = $bookstackShelfName;
return $this;
}
} }

View File

@@ -99,9 +99,15 @@ class Task
#[Groups(['task:read', 'task:write'])] #[Groups(['task:read', 'task:write'])]
private bool $archived = false; private bool $archived = false;
/** @var Collection<int, TaskDocument> */
#[ORM\OneToMany(targetEntity: TaskDocument::class, mappedBy: 'task', cascade: ['remove'])]
#[Groups(['task:read'])]
private Collection $documents;
public function __construct() public function __construct()
{ {
$this->tags = new ArrayCollection(); $this->tags = new ArrayCollection();
$this->documents = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -250,4 +256,10 @@ class Task
return $this; return $this;
} }
/** @return Collection<int, TaskDocument> */
public function getDocuments(): Collection
{
return $this->documents;
}
} }

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\TaskBookStackLinkRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: TaskBookStackLinkRepository::class)]
#[ORM\UniqueConstraint(name: 'UNIQ_task_bookstack_link', columns: ['task_id', 'bookstack_id', 'bookstack_type'])]
class TaskBookStackLink
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Task::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Task $task;
#[ORM\Column]
private int $bookstackId;
#[ORM\Column(length: 10)]
private string $bookstackType;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\Column(length: 500)]
private string $url;
#[ORM\Column]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getTask(): Task
{
return $this->task;
}
public function setTask(Task $task): static
{
$this->task = $task;
return $this;
}
public function getBookstackId(): int
{
return $this->bookstackId;
}
public function setBookstackId(int $bookstackId): static
{
$this->bookstackId = $bookstackId;
return $this;
}
public function getBookstackType(): string
{
return $this->bookstackType;
}
public function setBookstackType(string $bookstackType): static
{
$this->bookstackType = $bookstackType;
return $this;
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getUrl(): string
{
return $this->url;
}
public function setUrl(string $url): static
{
$this->url = $url;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}

164
src/Entity/TaskDocument.php Normal file
View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\EventListener\TaskDocumentListener;
use App\State\TaskDocumentProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false),
new Get(),
new Post(
security: "is_granted('ROLE_ADMIN')",
processor: TaskDocumentProcessor::class,
deserialize: false,
),
new Delete(security: "is_granted('ROLE_ADMIN')"),
],
normalizationContext: ['groups' => ['task_document:read']],
denormalizationContext: ['groups' => ['task_document:write']],
order: ['id' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact'])]
#[ORM\Entity]
#[ORM\EntityListeners([TaskDocumentListener::class])]
class TaskDocument
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['task_document:read', 'task:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['task_document:read', 'task_document:write'])]
private ?Task $task = null;
#[ORM\Column(length: 255)]
#[Groups(['task_document:read', 'task:read'])]
private ?string $originalName = null;
#[ORM\Column(length: 255)]
#[Groups(['task_document:read', 'task:read'])]
private ?string $fileName = null;
#[ORM\Column(length: 100)]
#[Groups(['task_document:read', 'task:read'])]
private ?string $mimeType = null;
#[ORM\Column]
#[Groups(['task_document:read', 'task:read'])]
private ?int $size = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['task_document:read', 'task:read'])]
private ?DateTimeImmutable $createdAt = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
#[Groups(['task_document:read', 'task:read'])]
private ?User $uploadedBy = null;
public function getId(): ?int
{
return $this->id;
}
public function getTask(): ?Task
{
return $this->task;
}
public function setTask(?Task $task): static
{
$this->task = $task;
return $this;
}
public function getOriginalName(): ?string
{
return $this->originalName;
}
public function setOriginalName(string $originalName): static
{
$this->originalName = $originalName;
return $this;
}
public function getFileName(): ?string
{
return $this->fileName;
}
public function setFileName(string $fileName): static
{
$this->fileName = $fileName;
return $this;
}
public function getMimeType(): ?string
{
return $this->mimeType;
}
public function setMimeType(string $mimeType): static
{
$this->mimeType = $mimeType;
return $this;
}
public function getSize(): ?int
{
return $this->size;
}
public function setSize(int $size): static
{
$this->size = $size;
return $this;
}
public function getCreatedAt(): ?DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getUploadedBy(): ?User
{
return $this->uploadedBy;
}
public function setUploadedBy(?User $uploadedBy): static
{
$this->uploadedBy = $uploadedBy;
return $this;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use App\Entity\TaskDocument;
use Doctrine\ORM\Event\PreRemoveEventArgs;
use Psr\Log\LoggerInterface;
class TaskDocumentListener
{
public function __construct(
private readonly string $uploadDir,
private readonly LoggerInterface $logger,
) {}
public function preRemove(TaskDocument $document, PreRemoveEventArgs $event): void
{
$filePath = $this->uploadDir.'/'.$document->getFileName();
if (file_exists($filePath)) {
if (!unlink($filePath)) {
$this->logger->warning('Failed to delete document file: {path}', ['path' => $filePath]);
}
} else {
$this->logger->warning('Document file not found on disk: {path}', ['path' => $filePath]);
}
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use RuntimeException;
use Throwable;
final class BookStackApiException extends RuntimeException
{
public function __construct(string $message, int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\BookStackConfiguration;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class BookStackConfigurationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, BookStackConfiguration::class);
}
public function findSingleton(): ?BookStackConfiguration
{
return $this->findOneBy([]);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\TaskBookStackLink;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class TaskBookStackLinkRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, TaskBookStackLink::class);
}
/** @return TaskBookStackLink[] */
public function findByTaskId(int $taskId): array
{
return $this->findBy(['task' => $taskId], ['createdAt' => 'DESC']);
}
}

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\BookStackConfiguration;
use App\Exception\BookStackApiException;
use App\Repository\BookStackConfigurationRepository;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
final class BookStackApiService
{
/** @var array<int, int[]> */
private array $shelfBookCache = [];
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly BookStackConfigurationRepository $configRepository,
private readonly TokenEncryptor $tokenEncryptor,
) {}
public function testConnection(): bool
{
try {
$this->request('GET', '/api/docs.json');
return true;
} catch (BookStackApiException) {
return false;
}
}
/**
* @return array<array{id: int, name: string}>
*/
public function listShelves(): array
{
$result = [];
$offset = 0;
$count = 100;
do {
$data = $this->request('GET', '/api/shelves', [
'query' => ['count' => $count, 'offset' => $offset],
]);
$items = $data['data'] ?? [];
$result = array_merge($result, $items);
$offset += $count;
} while (!empty($items) && $count === count($items));
return $result;
}
/**
* Search for pages and books within a specific shelf.
*
* Algorithm:
* 1. Fetch the shelf's book IDs
* 2. Run two search queries (one for pages, one for books)
* 3. Filter results: pages must belong to a book on the shelf, books must be on the shelf
*
* @return array<array{id: int, type: string, name: string, url: string}>
*/
public function searchInShelf(int $shelfId, string $query): array
{
$bookIds = $this->getShelfBookIds($shelfId);
if (empty($bookIds)) {
return [];
}
$config = $this->getConfiguration();
$baseUrl = rtrim($config->getUrl() ?? '', '/');
$trimmed = trim($query);
// BookStack search API accepts {type:X} for one type at a time — run two queries
$pageResults = $this->request('GET', '/api/search', [
'query' => ['query' => $trimmed.' {type:page}', 'count' => 50],
]);
$bookResults = $this->request('GET', '/api/search', [
'query' => ['query' => $trimmed.' {type:book}', 'count' => 50],
]);
$allResults = array_merge($pageResults['data'] ?? [], $bookResults['data'] ?? []);
// Build a map of bookId → bookSlug for URL construction
$shelfData = $this->request('GET', sprintf('/api/shelves/%d', $shelfId));
$bookSlugs = [];
foreach ($shelfData['books'] ?? [] as $book) {
$bookSlugs[$book['id']] = $book['slug'] ?? '';
}
$filtered = [];
foreach ($allResults as $item) {
$type = $item['type'] ?? '';
if ('page' === $type) {
$bookId = $item['book_id'] ?? 0;
if (in_array($bookId, $bookIds, true)) {
$bookSlug = $bookSlugs[$bookId] ?? '';
$filtered[] = [
'id' => $item['id'],
'type' => 'page',
'name' => $item['name'] ?? '',
'url' => $baseUrl.'/books/'.$bookSlug.'/page/'.$item['slug'],
];
}
} elseif ('book' === $type) {
if (in_array($item['id'], $bookIds, true)) {
$filtered[] = [
'id' => $item['id'],
'type' => 'book',
'name' => $item['name'] ?? '',
'url' => $baseUrl.'/books/'.$item['slug'],
];
}
}
}
return $filtered;
}
/**
* @return array{id: int, name: string, slug: string}
*/
public function getPage(int $id): array
{
return $this->request('GET', sprintf('/api/pages/%d', $id));
}
/**
* @return array{id: int, name: string, slug: string}
*/
public function getBook(int $id): array
{
return $this->request('GET', sprintf('/api/books/%d', $id));
}
/**
* @return int[]
*/
private function getShelfBookIds(int $shelfId): array
{
if (isset($this->shelfBookCache[$shelfId])) {
return $this->shelfBookCache[$shelfId];
}
$data = $this->request('GET', sprintf('/api/shelves/%d', $shelfId));
$books = $data['books'] ?? [];
$ids = array_map(static fn (array $book): int => $book['id'], $books);
$this->shelfBookCache[$shelfId] = $ids;
return $ids;
}
private function getConfiguration(): BookStackConfiguration
{
$config = $this->configRepository->findSingleton();
if (null === $config) {
throw new BookStackApiException('BookStack is not configured.');
}
return $config;
}
/**
* @return array{tokenId: string, tokenSecret: string}
*/
private function getDecryptedTokens(BookStackConfiguration $config): array
{
$encryptedId = $config->getEncryptedTokenId();
$encryptedSecret = $config->getEncryptedTokenSecret();
if (null === $encryptedId || null === $encryptedSecret) {
throw new BookStackApiException('BookStack tokens are not set.');
}
try {
return [
'tokenId' => $this->tokenEncryptor->decrypt($encryptedId),
'tokenSecret' => $this->tokenEncryptor->decrypt($encryptedSecret),
];
} catch (Throwable $e) {
throw new BookStackApiException('Failed to decrypt BookStack tokens: '.$e->getMessage(), 0, $e);
}
}
private function extractError(HttpExceptionInterface $e): string
{
try {
$body = $e->getResponse()->getContent(false);
$data = json_decode($body, true);
if (is_array($data)) {
return $data['message'] ?? $data['error'] ?? $body;
}
return $body ?: 'Unknown BookStack error';
} catch (ExceptionInterface) {
return 'BookStack API error (HTTP '.$e->getResponse()->getStatusCode().')';
}
}
/**
* @param array<string, mixed> $options
*/
private function request(string $method, string $path, array $options = []): array
{
$config = $this->getConfiguration();
$tokens = $this->getDecryptedTokens($config);
$options['headers'] = array_merge($options['headers'] ?? [], [
'Authorization' => sprintf('Token %s:%s', $tokens['tokenId'], $tokens['tokenSecret']),
'Accept' => 'application/json',
]);
$options['timeout'] = 10;
try {
$response = $this->httpClient->request($method, rtrim($config->getUrl(), '/').$path, $options);
return $response->toArray();
} catch (HttpExceptionInterface $e) {
$message = $this->extractError($e);
throw new BookStackApiException($message, $e->getResponse()->getStatusCode(), $e);
} catch (ExceptionInterface $e) {
throw new BookStackApiException('BookStack API error: '.$e->getMessage(), 0, $e);
}
}
}

View File

@@ -12,6 +12,7 @@ use App\Repository\GiteaConfigurationRepository;
use Symfony\Component\String\Slugger\AsciiSlugger; use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\String\Slugger\SluggerInterface; use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable; use Throwable;
@@ -133,17 +134,21 @@ final readonly class GiteaApiService
/** /**
* @return array<array{sha: string, commit: array{message: string, author: array}, created: string}> * @return array<array{sha: string, commit: array{message: string, author: array}, created: string}>
*/ */
public function listCommits(Project $project, string $branch): array public function listBranchCommits(Project $project, string $branch): array
{ {
$this->assertProjectHasRepo($project); $this->assertProjectHasRepo($project);
return $this->request('GET', sprintf( $defaultBranch = $this->getDefaultBranch($project);
'/api/v1/repos/%s/%s/commits',
$data = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s/compare/%s...%s',
$project->getGiteaOwner(), $project->getGiteaOwner(),
$project->getGiteaRepo(), $project->getGiteaRepo(),
), [ $defaultBranch,
'query' => ['sha' => $branch, 'limit' => 30], urlencode($branch),
]); ));
return $data['commits'] ?? [];
} }
/** /**
@@ -211,6 +216,22 @@ final readonly class GiteaApiService
} }
} }
private function extractGiteaError(HttpExceptionInterface $e): string
{
try {
$body = $e->getResponse()->getContent(false);
$data = json_decode($body, true);
if (is_array($data)) {
return $data['message'] ?? $data['error'] ?? $body;
}
return $body ?: 'Unknown Gitea error';
} catch (ExceptionInterface) {
return 'Gitea API error (HTTP '.$e->getResponse()->getStatusCode().')';
}
}
private function assertProjectHasRepo(Project $project): void private function assertProjectHasRepo(Project $project): void
{ {
if (!$project->hasGiteaRepo()) { if (!$project->hasGiteaRepo()) {
@@ -236,6 +257,10 @@ final readonly class GiteaApiService
$response = $this->httpClient->request($method, rtrim($config->getUrl(), '/').$path, $options); $response = $this->httpClient->request($method, rtrim($config->getUrl(), '/').$path, $options);
return $response->toArray(); return $response->toArray();
} catch (HttpExceptionInterface $e) {
$message = $this->extractGiteaError($e);
throw new GiteaApiException($message, $e->getResponse()->getStatusCode(), $e);
} catch (ExceptionInterface $e) { } catch (ExceptionInterface $e) {
throw new GiteaApiException('Gitea API error: '.$e->getMessage(), 0, $e); throw new GiteaApiException('Gitea API error: '.$e->getMessage(), 0, $e);
} }

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Exception\GiteaApiException;
use RuntimeException; use RuntimeException;
use SodiumException; use SodiumException;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -15,7 +14,7 @@ final class TokenEncryptor
private readonly bool $configured; private readonly bool $configured;
public function __construct( public function __construct(
#[Autowire('%env(GITEA_ENCRYPTION_KEY)%')] #[Autowire('%env(ENCRYPTION_KEY)%')]
string $encryptionKey, string $encryptionKey,
) { ) {
if ('' === $encryptionKey) { if ('' === $encryptionKey) {
@@ -75,7 +74,7 @@ final class TokenEncryptor
private function assertConfigured(): void private function assertConfigured(): void
{ {
if (!$this->configured) { if (!$this->configured) {
throw new GiteaApiException('Gitea encryption is not configured. Please set a valid GITEA_ENCRYPTION_KEY.'); throw new RuntimeException('Encryption is not configured. Please set a valid ENCRYPTION_KEY.');
} }
} }
} }

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\BookStackLink;
use App\Entity\Task;
use App\Entity\TaskBookStackLink;
use App\Repository\TaskBookStackLinkRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class BookStackLinkProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private TaskBookStackLinkRepository $linkRepository,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?BookStackLink
{
if ($operation instanceof Delete) {
return $this->handleDelete($uriVariables);
}
return $this->handleCreate($data, $uriVariables);
}
private function handleCreate(mixed $data, array $uriVariables): BookStackLink
{
assert($data instanceof BookStackLink);
$taskId = $uriVariables['taskId'] ?? 0;
$task = $this->em->getRepository(Task::class)->find($taskId);
if (null === $task) {
throw new NotFoundHttpException('Task not found.');
}
$link = new TaskBookStackLink();
$link->setTask($task);
$link->setBookstackId($data->bookstackId);
$link->setBookstackType($data->bookstackType);
$link->setTitle($data->title);
$link->setUrl($data->url);
$this->em->persist($link);
$this->em->flush();
$result = new BookStackLink();
$result->id = $link->getId();
$result->bookstackId = $link->getBookstackId();
$result->bookstackType = $link->getBookstackType();
$result->title = $link->getTitle();
$result->url = $link->getUrl();
$result->createdAt = $link->getCreatedAt()->format('c');
return $result;
}
private function handleDelete(array $uriVariables): null
{
$linkId = $uriVariables['id'] ?? 0;
$link = $this->linkRepository->find($linkId);
if (null === $link) {
throw new NotFoundHttpException('Link not found.');
}
$this->em->remove($link);
$this->em->flush();
return null;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\BookStackLink;
use App\Entity\TaskBookStackLink;
use App\Repository\TaskBookStackLinkRepository;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class BookStackLinkProvider implements ProviderInterface
{
public function __construct(
private TaskBookStackLinkRepository $linkRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|BookStackLink
{
if ($operation instanceof Post) {
return new BookStackLink();
}
if ($operation instanceof Delete) {
$link = $this->linkRepository->find($uriVariables['id'] ?? 0);
if (null === $link) {
throw new NotFoundHttpException('Link not found.');
}
$dto = new BookStackLink();
$dto->id = $link->getId();
return $dto;
}
$taskId = $uriVariables['taskId'] ?? 0;
$links = $this->linkRepository->findByTaskId($taskId);
return array_map(static function (TaskBookStackLink $link): BookStackLink {
$dto = new BookStackLink();
$dto->id = $link->getId();
$dto->bookstackId = $link->getBookstackId();
$dto->bookstackType = $link->getBookstackType();
$dto->title = $link->getTitle();
$dto->url = $link->getUrl();
$dto->createdAt = $link->getCreatedAt()->format('c');
return $dto;
}, $links);
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\BookStackSearchResult;
use App\Entity\Task;
use App\Exception\BookStackApiException;
use App\Service\BookStackApiService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class BookStackSearchResultProvider implements ProviderInterface
{
public function __construct(
private BookStackApiService $bookStackApiService,
private EntityManagerInterface $em,
private RequestStack $requestStack,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$taskId = $uriVariables['taskId'] ?? 0;
$task = $this->em->getRepository(Task::class)->find($taskId);
if (null === $task || null === $task->getProject()) {
return [];
}
$shelfId = $task->getProject()->getBookstackShelfId();
if (null === $shelfId) {
return [];
}
$request = $this->requestStack->getCurrentRequest();
$query = $request?->query->get('q', '') ?? '';
if ('' === trim($query)) {
return [];
}
try {
$results = $this->bookStackApiService->searchInShelf($shelfId, $query);
} catch (BookStackApiException $e) {
throw new BadRequestHttpException($e->getMessage(), $e);
}
return array_map(static function (array $item): BookStackSearchResult {
$dto = new BookStackSearchResult();
$dto->id = $item['id'];
$dto->type = $item['type'];
$dto->name = $item['name'];
$dto->url = $item['url'];
return $dto;
}, $results);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\BookStackSettings;
use App\Entity\BookStackConfiguration;
use App\Repository\BookStackConfigurationRepository;
use App\Service\TokenEncryptor;
use Doctrine\ORM\EntityManagerInterface;
final readonly class BookStackSettingsProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $em,
private BookStackConfigurationRepository $configRepository,
private TokenEncryptor $tokenEncryptor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): BookStackSettings
{
assert($data instanceof BookStackSettings);
$config = $this->configRepository->findSingleton();
if (null === $config) {
$config = new BookStackConfiguration();
}
$config->setUrl($data->url);
if (null !== $data->tokenId && '' !== $data->tokenId
&& null !== $data->tokenSecret && '' !== $data->tokenSecret) {
$config->setEncryptedTokenId($this->tokenEncryptor->encrypt($data->tokenId));
$config->setEncryptedTokenSecret($this->tokenEncryptor->encrypt($data->tokenSecret));
}
$this->em->persist($config);
$this->em->flush();
$result = new BookStackSettings();
$result->url = $config->getUrl();
$result->hasToken = $config->hasToken();
return $result;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\BookStackSettings;
use App\Repository\BookStackConfigurationRepository;
final readonly class BookStackSettingsProvider implements ProviderInterface
{
public function __construct(
private BookStackConfigurationRepository $configRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BookStackSettings
{
$config = $this->configRepository->findSingleton();
$dto = new BookStackSettings();
if (null !== $config) {
$dto->url = $config->getUrl();
$dto->hasToken = $config->hasToken();
}
return $dto;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\BookStackShelf;
use App\Exception\BookStackApiException;
use App\Service\BookStackApiService;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class BookStackShelfProvider implements ProviderInterface
{
public function __construct(
private BookStackApiService $bookStackApiService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
try {
$shelves = $this->bookStackApiService->listShelves();
} catch (BookStackApiException $e) {
throw new BadRequestHttpException($e->getMessage(), $e);
}
return array_map(static function (array $shelf): BookStackShelf {
$dto = new BookStackShelf();
$dto->id = $shelf['id'] ?? 0;
$dto->name = $shelf['name'] ?? '';
return $dto;
}, $shelves);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\BookStackTestConnection;
use App\Service\BookStackApiService;
final readonly class BookStackTestConnectionProvider implements ProviderInterface, ProcessorInterface
{
public function __construct(
private BookStackApiService $bookStackApiService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BookStackTestConnection
{
return new BookStackTestConnection();
}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): BookStackTestConnection
{
$result = new BookStackTestConnection();
$result->success = $this->bookStackApiService->testConnection();
return $result;
}
}

View File

@@ -12,6 +12,7 @@ use App\Entity\Task;
use App\Exception\GiteaApiException; use App\Exception\GiteaApiException;
use App\Service\GiteaApiService; use App\Service\GiteaApiService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class GiteaBranchProvider implements ProviderInterface final readonly class GiteaBranchProvider implements ProviderInterface
{ {
@@ -40,8 +41,8 @@ final readonly class GiteaBranchProvider implements ProviderInterface
try { try {
$branches = $this->giteaApiService->listBranches($project, $taskCode); $branches = $this->giteaApiService->listBranches($project, $taskCode);
} catch (GiteaApiException) { } catch (GiteaApiException $e) {
return []; throw new BadRequestHttpException($e->getMessage(), $e);
} }
$result = []; $result = [];
@@ -50,7 +51,7 @@ final readonly class GiteaBranchProvider implements ProviderInterface
$dto->name = $branch['name']; $dto->name = $branch['name'];
try { try {
$commits = $this->giteaApiService->listCommits($project, $branch['name']); $commits = $this->giteaApiService->listBranchCommits($project, $branch['name']);
$dto->commits = array_map(static fn (array $c): array => [ $dto->commits = array_map(static fn (array $c): array => [
'sha' => substr($c['sha'] ?? '', 0, 7), 'sha' => substr($c['sha'] ?? '', 0, 7),
'message' => $c['commit']['message'] ?? '', 'message' => $c['commit']['message'] ?? '',
@@ -58,6 +59,7 @@ final readonly class GiteaBranchProvider implements ProviderInterface
'date' => $c['commit']['author']['date'] ?? $c['created'] ?? '', 'date' => $c['commit']['author']['date'] ?? $c['created'] ?? '',
], $commits); ], $commits);
} catch (GiteaApiException) { } catch (GiteaApiException) {
// Commits fetch failure should not block branch listing
$dto->commits = []; $dto->commits = [];
} }

View File

@@ -11,6 +11,7 @@ use App\Entity\Task;
use App\Exception\GiteaApiException; use App\Exception\GiteaApiException;
use App\Service\GiteaApiService; use App\Service\GiteaApiService;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class GiteaPullRequestProvider implements ProviderInterface final readonly class GiteaPullRequestProvider implements ProviderInterface
{ {
@@ -35,8 +36,8 @@ final readonly class GiteaPullRequestProvider implements ProviderInterface
try { try {
$prs = $this->giteaApiService->listPullRequests($project, $taskCode); $prs = $this->giteaApiService->listPullRequests($project, $taskCode);
} catch (GiteaApiException) { } catch (GiteaApiException $e) {
return []; throw new BadRequestHttpException($e->getMessage(), $e);
} }
return array_map(static function (array $pr): GiteaPullRequest { return array_map(static function (array $pr): GiteaPullRequest {

View File

@@ -9,6 +9,7 @@ use ApiPlatform\State\ProviderInterface;
use App\ApiResource\GiteaRepository; use App\ApiResource\GiteaRepository;
use App\Exception\GiteaApiException; use App\Exception\GiteaApiException;
use App\Service\GiteaApiService; use App\Service\GiteaApiService;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class GiteaRepositoryProvider implements ProviderInterface final readonly class GiteaRepositoryProvider implements ProviderInterface
{ {
@@ -20,8 +21,8 @@ final readonly class GiteaRepositoryProvider implements ProviderInterface
{ {
try { try {
$repos = $this->giteaApiService->listRepositories(); $repos = $this->giteaApiService->listRepositories();
} catch (GiteaApiException) { } catch (GiteaApiException $e) {
return []; throw new BadRequestHttpException($e->getMessage(), $e);
} }
return array_map(static function (array $repo): GiteaRepository { return array_map(static function (array $repo): GiteaRepository {

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Task;
use App\Entity\TaskDocument;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;
/**
* @implements ProcessorInterface<TaskDocument, TaskDocument>
*/
final readonly class TaskDocumentProcessor implements ProcessorInterface
{
private const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
private RequestStack $requestStack,
private string $uploadDir,
) {}
/**
* @param TaskDocument $data
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument
{
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
throw new BadRequestHttpException('No request available.');
}
$file = $request->files->get('file');
if (null === $file || !$file->isValid()) {
throw new BadRequestHttpException('No valid file uploaded.');
}
if ($file->getSize() > self::MAX_FILE_SIZE) {
throw new BadRequestHttpException('File size exceeds 50 MB limit.');
}
$taskIri = $request->request->get('task');
if (null === $taskIri || '' === $taskIri) {
throw new BadRequestHttpException('Task IRI is required.');
}
// Extract task ID from IRI (e.g., "/api/tasks/42" -> 42)
$taskId = (int) basename((string) $taskIri);
$task = $this->entityManager->getRepository(Task::class)->find($taskId);
if (null === $task) {
throw new BadRequestHttpException('Task not found.');
}
// Capture file metadata BEFORE move() — move invalidates the temp file
$originalName = $file->getClientOriginalName();
$extension = $file->getClientOriginalExtension() ?: 'bin';
$mimeType = $file->getClientMimeType() ?? 'application/octet-stream';
$fileSize = $file->getSize();
$uuid = Uuid::v4()->toRfc4122();
$fileName = $uuid.'.'.$extension;
if (!is_dir($this->uploadDir)) {
mkdir($this->uploadDir, 0o775, true);
}
$file->move($this->uploadDir, $fileName);
$document = new TaskDocument();
$document->setTask($task);
$document->setOriginalName($originalName);
$document->setFileName($fileName);
$document->setMimeType($mimeType);
$document->setSize($fileSize);
$document->setCreatedAt(new DateTimeImmutable());
$document->setUploadedBy($this->security->getUser());
$this->entityManager->persist($document);
$this->entityManager->flush();
return $document;
}
}