Compare commits
47 Commits
f888a29e0a
...
v0.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4216f1b5a1 | ||
| c72f17eb93 | |||
| 4c19b68156 | |||
| 63e4af785e | |||
| f5e41bc377 | |||
| f978df6a4b | |||
| 98e832afa5 | |||
| cbfbb16c59 | |||
| 354d994766 | |||
| 06771c17e0 | |||
| 9908f34580 | |||
| 7bf632c1da | |||
| 66a75c6b6a | |||
| f53b2f3d1f | |||
| c9a3c7c5f8 | |||
| 5777e8386f | |||
| 06f2a9e1ea | |||
| b5fa9e7d06 | |||
| 73ecbbc95b | |||
| 5327155a80 | |||
| 9e638c32b8 | |||
| bc331982d5 | |||
| 1e311242a9 | |||
| 97c6ef6a52 | |||
| 245a8a932e | |||
| 28fbc73248 | |||
| df00b27a64 | |||
| ee38f99022 | |||
| 48ef434f8b | |||
| e53862d71f | |||
| 52063cb4fa | |||
| 06832c24e1 | |||
| 8fbafc1f8a | |||
| 585cc3368f | |||
| 043826075d | |||
| 8ec98a593a | |||
| 3dd2d39222 | |||
| cfaa6c42ec | |||
| a36cd92a7f | |||
| bfffbe7041 | |||
| c9993ef32d | |||
| efc3742fff | |||
| e047b98bed | |||
| 758c9f6fbd | |||
| 2c93e83e6b | |||
| 25b648a1b1 | |||
| 445f51b473 |
2
.env
2
.env
@@ -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
|
||||||
11
CLAUDE.md
11
CLAUDE.md
@@ -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/)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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%'
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.0'
|
app.version: '0.1.1'
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
2148
docs/superpowers/plans/2026-03-15-bookstack-connector.md
Normal file
2148
docs/superpowers/plans/2026-03-15-bookstack-connector.md
Normal file
File diff suppressed because it is too large
Load Diff
1302
docs/superpowers/plans/2026-03-15-task-documents.md
Normal file
1302
docs/superpowers/plans/2026-03-15-task-documents.md
Normal file
File diff suppressed because it is too large
Load Diff
197
docs/superpowers/specs/2026-03-10-time-tracking-design.md
Normal file
197
docs/superpowers/specs/2026-03-10-time-tracking-design.md
Normal 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 |
|
||||||
316
docs/superpowers/specs/2026-03-15-bookstack-connector-design.md
Normal file
316
docs/superpowers/specs/2026-03-15-bookstack-connector-design.md
Normal 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)
|
||||||
|
```
|
||||||
523
docs/superpowers/specs/2026-03-15-client-portal-design.md
Normal file
523
docs/superpowers/specs/2026-03-15-client-portal-design.md
Normal 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)
|
||||||
218
docs/superpowers/specs/2026-03-15-task-documents-design.md
Normal file
218
docs/superpowers/specs/2026-03-15-task-documents-design.md
Normal 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.
|
||||||
116
frontend/components/admin/AdminBookStackTab.vue
Normal file
116
frontend/components/admin/AdminBookStackTab.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
158
frontend/components/task/TaskBookStackLinks.vue
Normal file
158
frontend/components/task/TaskBookStackLinks.vue
Normal 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>
|
||||||
80
frontend/components/task/TaskDocumentList.vue
Normal file
80
frontend/components/task/TaskDocumentList.vue
Normal 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>
|
||||||
124
frontend/components/task/TaskDocumentPreview.vue
Normal file
124
frontend/components/task/TaskDocumentPreview.vue
Normal 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>
|
||||||
133
frontend/components/task/TaskDocumentUpload.vue
Normal file
133
frontend/components/task/TaskDocumentUpload.vue
Normal 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>
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
58
frontend/components/ui/ConfirmDeleteDocumentModal.vue
Normal file
58
frontend/components/ui/ConfirmDeleteDocumentModal.vue
Normal 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>
|
||||||
@@ -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é"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
66
frontend/services/bookstack.ts
Normal file
66
frontend/services/bookstack.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
42
frontend/services/dto/bookstack.ts
Normal file
42
frontend/services/dto/bookstack.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
13
frontend/services/dto/task-document.ts
Normal file
13
frontend/services/dto/task-document.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
frontend/services/task-documents.ts
Normal file
42
frontend/services/task-documents.ts
Normal 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 }
|
||||||
|
}
|
||||||
39
migrations/Version20260315170358.php
Normal file
39
migrations/Version20260315170358.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
42
migrations/Version20260315170552.php
Normal file
42
migrations/Version20260315170552.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/ApiResource/BookStackLink.php
Normal file
71
src/ApiResource/BookStackLink.php
Normal 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;
|
||||||
|
}
|
||||||
40
src/ApiResource/BookStackSearchResult.php
Normal file
40
src/ApiResource/BookStackSearchResult.php
Normal 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 = '';
|
||||||
|
}
|
||||||
45
src/ApiResource/BookStackSettings.php
Normal file
45
src/ApiResource/BookStackSettings.php
Normal 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;
|
||||||
|
}
|
||||||
29
src/ApiResource/BookStackShelf.php
Normal file
29
src/ApiResource/BookStackShelf.php
Normal 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 = '';
|
||||||
|
}
|
||||||
28
src/ApiResource/BookStackTestConnection.php
Normal file
28
src/ApiResource/BookStackTestConnection.php
Normal 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;
|
||||||
|
}
|
||||||
52
src/Controller/TaskDocumentDownloadController.php
Normal file
52
src/Controller/TaskDocumentDownloadController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/Entity/BookStackConfiguration.php
Normal file
72
src/Entity/BookStackConfiguration.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
113
src/Entity/TaskBookStackLink.php
Normal file
113
src/Entity/TaskBookStackLink.php
Normal 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
164
src/Entity/TaskDocument.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/EventListener/TaskDocumentListener.php
Normal file
30
src/EventListener/TaskDocumentListener.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/Exception/BookStackApiException.php
Normal file
16
src/Exception/BookStackApiException.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/Repository/BookStackConfigurationRepository.php
Normal file
22
src/Repository/BookStackConfigurationRepository.php
Normal 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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/Repository/TaskBookStackLinkRepository.php
Normal file
23
src/Repository/TaskBookStackLinkRepository.php
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
235
src/Service/BookStackApiService.php
Normal file
235
src/Service/BookStackApiService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
src/State/BookStackLinkProcessor.php
Normal file
79
src/State/BookStackLinkProcessor.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/State/BookStackLinkProvider.php
Normal file
54
src/State/BookStackLinkProvider.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/State/BookStackSearchResultProvider.php
Normal file
62
src/State/BookStackSearchResultProvider.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/State/BookStackSettingsProcessor.php
Normal file
49
src/State/BookStackSettingsProcessor.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/State/BookStackSettingsProvider.php
Normal file
30
src/State/BookStackSettingsProvider.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/State/BookStackShelfProvider.php
Normal file
36
src/State/BookStackShelfProvider.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/State/BookStackTestConnectionProvider.php
Normal file
31
src/State/BookStackTestConnectionProvider.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
95
src/State/TaskDocumentProcessor.php
Normal file
95
src/State/TaskDocumentProcessor.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user