Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4fdb84a17 | ||
|
|
5585fa7ef6 | ||
|
|
b301ebbad0 | ||
|
|
feaa9f1875 | ||
|
|
b25be8fd6a | ||
|
|
3e6b0e877a | ||
|
|
9f3fc05a52 | ||
|
|
4c3721b6ac | ||
|
|
06d733f88e | ||
|
|
258c6e9c17 | ||
| feffe63019 | |||
| 34ba554fba | |||
| b2cc6e96e1 | |||
| 2a68d2f9c6 | |||
| 2898b22440 | |||
|
|
f1fd80d9ac | ||
|
|
24e3e8e989 | ||
|
|
47f2ab9cd4 | ||
|
|
36729f8f61 | ||
|
|
30b090852d | ||
|
|
f0c9568521 | ||
|
|
7c37eb58cb | ||
|
|
7a5b8dabff | ||
|
|
fef563be06 | ||
|
|
e14c707dfd | ||
|
|
fa7bb27ef5 | ||
|
|
21e9d2cab4 | ||
|
|
00ffcb1cf2 | ||
|
|
daba09472f | ||
|
|
f3208a481f | ||
|
|
a46542fcdd | ||
|
|
1ae2d9ac2c | ||
|
|
e41caa9cfe | ||
|
|
916f4ae101 | ||
| 45d389c67f | |||
|
|
7f12332cf6 | ||
| fe30f03b9f | |||
|
|
fc472d5dad | ||
| a0a2f27eac | |||
|
|
bd7adec2f0 | ||
| 9b6386c4ae | |||
|
|
9da1ae7ca1 | ||
| bc8bed3339 | |||
|
|
3fee678bd2 | ||
| be720178c2 | |||
|
|
eec0294f3e | ||
| 59a1c7956c | |||
|
|
e86949a1d7 | ||
|
|
7ca62bfc46 | ||
|
|
b60e4ae670 | ||
| ace52f8fc5 | |||
| 1ae9535516 | |||
|
|
b50cfb5049 | ||
|
|
a5227b9936 | ||
|
|
0d298db797 | ||
|
|
cbe71a1f32 | ||
|
|
a8fa8fd7e0 | ||
|
|
4aa2abd396 | ||
|
|
fa3326e99c | ||
|
|
21e050ce29 |
61
.claude/skills/ticket-executor/LEARNINGS.md
Normal file
61
.claude/skills/ticket-executor/LEARNINGS.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Ticket Executor - Learnings
|
||||||
|
|
||||||
|
## Session 2026-03-17 (26 tickets)
|
||||||
|
|
||||||
|
### T-001 — Secrets .env
|
||||||
|
- **Pattern**: Replace secrets with `change_me_in_env_local` placeholder, move real values to `.env.local`
|
||||||
|
- **Gotcha**: `.env.local` must contain ALL overridden secrets
|
||||||
|
|
||||||
|
### T-002 — Security API Gitea
|
||||||
|
- **Pattern**: Ajouter `security: "is_granted('ROLE_USER')"` sur les opérations ApiResource
|
||||||
|
- **Learning**: Vérifier d'abord les ressources déjà sécurisées pour ne pas dupliquer
|
||||||
|
|
||||||
|
### T-003 — SVG Upload
|
||||||
|
- **Pattern**: Double protection - bloquer à l'upload (retirer du MIME allowlist) + defense-in-depth (Content-Disposition: attachment au download)
|
||||||
|
- **Learning**: Toujours vérifier upload ET download controllers
|
||||||
|
|
||||||
|
### T-004 — MCP create-task / Repos numérotation
|
||||||
|
- **Gotcha critique**: PostgreSQL n'autorise PAS `FOR UPDATE` avec des fonctions d'agrégation (`MAX`)
|
||||||
|
- **Fix**: Utiliser `pg_advisory_xact_lock()` au lieu de `FOR UPDATE` pour les queries avec agrégation
|
||||||
|
- **Pattern**: Offset les lock keys (+1000000) pour éviter collisions entre Task et ClientTicket
|
||||||
|
|
||||||
|
### T-005 — Filter ROLE_CLIENT projects
|
||||||
|
- **Pattern**: Créer une Doctrine Extension (`QueryCollectionExtensionInterface` + `QueryItemExtensionInterface`) pour filtrer par relation
|
||||||
|
- **Learning**: Symfony autoconfigure enregistre l'extension automatiquement
|
||||||
|
|
||||||
|
### T-006 — Block client doc upload
|
||||||
|
- **Pattern**: Vérifier le rôle dans le Processor AVANT de résoudre l'IRI de la tâche
|
||||||
|
- **Learning**: Le portail client envoie un `clientTicket` IRI (pas de `task` IRI), donc le check sur `taskIri` non-vide suffit
|
||||||
|
|
||||||
|
### T-007 — MCP role checks
|
||||||
|
- **Pattern**: Injecter `Security` dans chaque Tool, vérifier au début de `__invoke()`
|
||||||
|
- **Learning**: 22 tools à modifier - bien séparer ROLE_ADMIN (users/clients) vs ROLE_USER (le reste)
|
||||||
|
|
||||||
|
### T-009 — Password hashing
|
||||||
|
- **Pattern**: Champ `plainPassword` non-persisté, writable uniquement, hashé dans le Processor
|
||||||
|
- **Learning**: Modifier aussi le frontend (DTO + composant) quand on renomme un champ API
|
||||||
|
|
||||||
|
### T-010 — Rate limiting
|
||||||
|
- **Gotcha**: `login_throttling` nécessite `symfony/rate-limiter` installé, pas juste dans composer.json
|
||||||
|
- **Learning**: Toujours vérifier que les packages sont installés, pas juste déclarés
|
||||||
|
|
||||||
|
### T-012 — Harmoniser repos numérotation
|
||||||
|
- **Pattern**: Aligner les contrats (retourner le max, pas le next) et mettre le +1 côté appelant
|
||||||
|
- **Learning**: Vérifier TOUS les appelants d'une méthode renommée
|
||||||
|
|
||||||
|
### T-015 — useAvatarService
|
||||||
|
- **Learning**: Quand on migre vers `useApi()`, ajouter la détection FormData pour ne pas écraser le Content-Type multipart
|
||||||
|
|
||||||
|
### T-020 — i18n
|
||||||
|
- **Pattern**: Ajouter `useI18n()` dans le setup script avant de pouvoir utiliser `t()` dans le JS
|
||||||
|
- **Learning**: Les templates peuvent utiliser `$t()` directement sans import
|
||||||
|
|
||||||
|
### T-022 — Retirer twig-bundle
|
||||||
|
- **Pattern**: Retirer de composer.json + bundles.php + supprimer config YAML + templates
|
||||||
|
- **Learning**: API Platform ne requiert PAS twig, c'est juste suggéré pour Swagger UI
|
||||||
|
|
||||||
|
## Meta-learnings
|
||||||
|
- **Parallélisation**: Les tickets touchant des fichiers indépendants peuvent tourner en parallèle sans problème
|
||||||
|
- **MCP status**: Toujours mettre "En cours" AVANT de commencer, "Terminé" APRÈS validation
|
||||||
|
- **PostgreSQL gotchas**: Tester les queries SQL avec agrégation + locking sur PostgreSQL, pas MySQL
|
||||||
|
- **Agents**: Les agents simples (1-3 fichiers) terminent en ~30s, les complexes (22 fichiers) en ~8min
|
||||||
78
.claude/skills/ticket-executor/SKILL.md
Normal file
78
.claude/skills/ticket-executor/SKILL.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
name: ticket-executor
|
||||||
|
description: Execute Lesstime project tickets systematically - updates MCP statuses, follows project conventions, and logs learnings for self-improvement
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ticket Executor Skill
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Execute Lesstime project tickets end-to-end: read the ticket, implement the fix, update MCP status, and log learnings.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 1. Receive Ticket
|
||||||
|
- Get ticket ID, title, description, tags (Backend/Frontend), priority, and current status
|
||||||
|
- Understand the scope from the title and description
|
||||||
|
|
||||||
|
### 2. Set Status to "En cours" (ID: 2)
|
||||||
|
- Use MCP `update-task` with `statusId: 2` before starting work
|
||||||
|
- MCP endpoint: `http://project.malio-dev.fr/_mcp`
|
||||||
|
- Auth: `Bearer 7e8b410a5b79b5c0432951dcee3a3a81e0731e86d9f70d8784ec079a2b759c64`
|
||||||
|
|
||||||
|
### 3. Analyze & Implement
|
||||||
|
Based on tag:
|
||||||
|
- **Backend**: Check `src/Entity/`, `src/State/`, `src/Controller/`, `src/Security/`, `config/`
|
||||||
|
- **Frontend**: Check `frontend/components/`, `frontend/composables/`, `frontend/pages/`, `frontend/services/`
|
||||||
|
|
||||||
|
Conventions to follow:
|
||||||
|
- PHP: `declare(strict_types=1)`, Symfony + PSR-12, API Platform patterns
|
||||||
|
- Frontend: TypeScript strict, `useApi()` composable, 4 spaces indent
|
||||||
|
- See CLAUDE.md for full conventions
|
||||||
|
|
||||||
|
### 4. Verify
|
||||||
|
- For Backend: `make php-cs-fixer-allow-risky` if PHP changed
|
||||||
|
- For Frontend: check TypeScript types, no `any`
|
||||||
|
- Read modified files to confirm correctness
|
||||||
|
|
||||||
|
### 5. Set Status to "Terminé" (ID: 5)
|
||||||
|
- Use MCP `update-task` with `statusId: 5` after successful implementation
|
||||||
|
|
||||||
|
### 6. Log Learnings
|
||||||
|
Append to `.claude/skills/ticket-executor/LEARNINGS.md`:
|
||||||
|
- What worked well
|
||||||
|
- Patterns discovered
|
||||||
|
- Gotchas encountered
|
||||||
|
- Time-saving shortcuts found
|
||||||
|
|
||||||
|
## MCP Session Management
|
||||||
|
The MCP HTTP transport requires a session. To call tools:
|
||||||
|
```bash
|
||||||
|
# Initialize session (get Mcp-Session-Id from response header)
|
||||||
|
curl -si -X POST http://project.malio-dev.fr/_mcp \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"claude","version":"1.0"}}}'
|
||||||
|
|
||||||
|
# Call tool (use Mcp-Session-Id from init response)
|
||||||
|
curl -s -X POST http://project.malio-dev.fr/_mcp \
|
||||||
|
-H "Authorization: Bearer <token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Mcp-Session-Id: <session-id>" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"update-task","arguments":{"id":<taskId>,"statusId":<statusId>}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status IDs
|
||||||
|
- 1 = A faire
|
||||||
|
- 2 = En cours
|
||||||
|
- 3 = Bloqué
|
||||||
|
- 4 = En attente de validation
|
||||||
|
- 5 = Terminé
|
||||||
|
|
||||||
|
## Learnings Integration
|
||||||
|
Before each ticket, read `LEARNINGS.md` to apply previous insights.
|
||||||
|
After each ticket, append new learnings. This creates a feedback loop that improves execution quality over time.
|
||||||
|
|
||||||
|
## Parallel Execution Rules
|
||||||
|
- Independent tickets (no shared files) can run in parallel via worktree agents
|
||||||
|
- Tickets modifying the same files must run sequentially
|
||||||
|
- Always verify no merge conflicts after parallel execution
|
||||||
24
.dockerignore
Normal file
24
.dockerignore
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.env.local
|
||||||
|
.env.test
|
||||||
|
infra/dev/
|
||||||
|
infra/prod/docker-compose.yml
|
||||||
|
infra/prod/deploy.sh
|
||||||
|
infra/prod/deploy-release.sh
|
||||||
|
infra/prod/.env.example
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/.nuxt
|
||||||
|
frontend/.output
|
||||||
|
var/
|
||||||
|
vendor/
|
||||||
|
LOG/
|
||||||
|
docs/
|
||||||
|
tests/
|
||||||
|
*.sql
|
||||||
|
*.xlsx
|
||||||
|
*.png
|
||||||
|
*.md
|
||||||
|
!composer.lock
|
||||||
|
!symfony.lock
|
||||||
|
!frontend/package-lock.json
|
||||||
@@ -60,7 +60,7 @@ JWT_COOKIE_TTL=86400
|
|||||||
# Base de donnees (Doctrine / PostgreSQL)
|
# Base de donnees (Doctrine / PostgreSQL)
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
# Les variables POSTGRES_* sont definies dans docker/.env.docker
|
# Les variables POSTGRES_* sont definies dans infra/dev/.env.docker
|
||||||
# et injectees automatiquement par Docker Compose.
|
# et injectees automatiquement par Docker Compose.
|
||||||
# DATABASE_URL est construite a partir de ces variables.
|
# DATABASE_URL est construite a partir de ces variables.
|
||||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
|
||||||
@@ -74,10 +74,10 @@ DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_P
|
|||||||
ENCRYPTION_KEY=change_me_in_env_local
|
ENCRYPTION_KEY=change_me_in_env_local
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Docker (docker/.env.docker)
|
# Docker (infra/dev/.env.docker)
|
||||||
#
|
#
|
||||||
# Ces variables sont lues par Docker Compose. Voir docker/.env.docker
|
# Ces variables sont lues par Docker Compose. Voir infra/dev/.env.docker
|
||||||
# pour les valeurs par defaut. Creez docker/.env.docker.local pour
|
# pour les valeurs par defaut. Creez infra/dev/.env.docker.local pour
|
||||||
# surcharger localement.
|
# surcharger localement.
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
|
|||||||
30
.gitea/workflows/build-docker.yml
Normal file
30
.gitea/workflows/build-docker.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: Build & Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Login to Gitea Registry
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.malio.fr -u "${{ gitea.repository_owner }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
-f infra/prod/Dockerfile \
|
||||||
|
-t gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }} \
|
||||||
|
-t gitea.malio.fr/malio-dev/lesstime:latest \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
run: |
|
||||||
|
docker push gitea.malio.fr/malio-dev/lesstime:${{ gitea.ref_name }}
|
||||||
|
docker push gitea.malio.fr/malio-dev/lesstime:latest
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
name: Build Release Artefact
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: "8.4"
|
|
||||||
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "lts/*"
|
|
||||||
|
|
||||||
- name: Install backend deps (prod)
|
|
||||||
env:
|
|
||||||
APP_ENV: prod
|
|
||||||
APP_DEBUG: "0"
|
|
||||||
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
|
||||||
|
|
||||||
- name: Build frontend (static)
|
|
||||||
run: |
|
|
||||||
cd frontend
|
|
||||||
npm ci
|
|
||||||
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
|
|
||||||
test -f .output/public/index.html
|
|
||||||
|
|
||||||
- name: Build artefact
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p release
|
|
||||||
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
|
|
||||||
.env \
|
|
||||||
bin \
|
|
||||||
config \
|
|
||||||
migrations \
|
|
||||||
public \
|
|
||||||
src \
|
|
||||||
vendor \
|
|
||||||
composer.json \
|
|
||||||
composer.lock \
|
|
||||||
symfony.lock \
|
|
||||||
frontend/.output
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
uses: softprops/action-gh-release@v2
|
|
||||||
with:
|
|
||||||
files: release/lesstime-${{ github.ref_name }}.tar.gz
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -28,5 +28,5 @@
|
|||||||
###< ide ###
|
###< ide ###
|
||||||
|
|
||||||
###> docker local ###
|
###> docker local ###
|
||||||
docker/.env.docker.local
|
infra/dev/.env.docker.local
|
||||||
###< docker local ###
|
###< docker local ###
|
||||||
|
|||||||
15
CLAUDE.md
15
CLAUDE.md
@@ -103,6 +103,10 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
|
- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal`
|
||||||
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
|
- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions
|
||||||
|
|
||||||
|
### Composants UI
|
||||||
|
|
||||||
|
La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action. La documentation complète des props, events et exemples d'utilisation se trouve dans `frontend/node_modules/@malio/layer-ui/COMPONENTS.md`. Toujours s'y référer avant d'utiliser un composant Malio.
|
||||||
|
|
||||||
### MCP Server
|
### MCP Server
|
||||||
|
|
||||||
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
|
- 25 tools MCP exposant projets, tâches, métadonnées, time tracking, et récurrences
|
||||||
@@ -125,7 +129,7 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- Container PHP : `php-lesstime-fpm`
|
- Container PHP : `php-lesstime-fpm`
|
||||||
- Container Nginx : `nginx-lesstime`
|
- Container Nginx : `nginx-lesstime`
|
||||||
- Container DB : PostgreSQL sur port **5435** (interne et externe)
|
- Container DB : PostgreSQL sur port **5435** (interne et externe)
|
||||||
- Config Docker : `docker/.env.docker` (override local : `docker/.env.docker.local`)
|
- Config Docker : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||||
- Après modif nginx : `docker restart nginx-lesstime`
|
- Après modif nginx : `docker restart nginx-lesstime`
|
||||||
|
|
||||||
## Fixtures
|
## Fixtures
|
||||||
@@ -136,3 +140,12 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
|||||||
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
- API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production`
|
||||||
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
|
- ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false
|
||||||
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)
|
- TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH)
|
||||||
|
|
||||||
|
## Delegation Codex
|
||||||
|
|
||||||
|
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
|
||||||
|
|
||||||
|
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
|
||||||
|
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
|
||||||
|
|
||||||
|
C'est le meilleur ratio qualite/credits.
|
||||||
|
|||||||
0
LOG/xdebug.log
Normal file
0
LOG/xdebug.log
Normal file
@@ -156,7 +156,7 @@ docker/ # Dockerfiles et config Nginx
|
|||||||
| `nginx-lesstime` | 8082 | Nginx reverse proxy |
|
| `nginx-lesstime` | 8082 | Nginx reverse proxy |
|
||||||
| PostgreSQL | 5435 | Base de données |
|
| PostgreSQL | 5435 | Base de données |
|
||||||
|
|
||||||
Configuration : `docker/.env.docker` (override local : `docker/.env.docker.local`)
|
Configuration : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker.local`)
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
|||||||
@@ -21,3 +21,6 @@ mcp:
|
|||||||
store: file
|
store: file
|
||||||
directory: '%kernel.project_dir%/var/mcp-sessions'
|
directory: '%kernel.project_dir%/var/mcp-sessions'
|
||||||
ttl: 3600
|
ttl: 3600
|
||||||
|
discovery:
|
||||||
|
scan_dirs: ['src']
|
||||||
|
exclude_dirs: ['DataFixtures']
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.3.13'
|
app.version: '0.3.34'
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name project.malio-dev.fr;
|
|
||||||
|
|
||||||
root /var/www/lesstime/frontend/.output/public;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
client_max_body_size 55m;
|
|
||||||
|
|
||||||
location ^~ /api/ {
|
|
||||||
root /var/www/lesstime/public;
|
|
||||||
try_files $uri /index.php?$query_string;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /bundles/ {
|
|
||||||
root /var/www/lesstime/public;
|
|
||||||
try_files $uri =404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /api/login_check {
|
|
||||||
include fastcgi_params;
|
|
||||||
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
|
||||||
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
|
||||||
fastcgi_param SCRIPT_NAME /index.php;
|
|
||||||
fastcgi_param PATH_INFO /login_check;
|
|
||||||
fastcgi_param REQUEST_URI /login_check;
|
|
||||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /_mcp {
|
|
||||||
root /var/www/lesstime/public;
|
|
||||||
try_files $uri /index.php?$query_string;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/index\.php(/|$) {
|
|
||||||
include fastcgi_params;
|
|
||||||
fastcgi_param SCRIPT_FILENAME /var/www/lesstime/public/index.php;
|
|
||||||
fastcgi_param DOCUMENT_ROOT /var/www/lesstime/public;
|
|
||||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ \.php$ {
|
|
||||||
return 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
364
doc/deployment-docker.md
Normal file
364
doc/deployment-docker.md
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
# Deploiement Docker — Lesstime
|
||||||
|
|
||||||
|
## Pre-requis
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y ca-certificates curl gnupg
|
||||||
|
sudo install -m 0755 -d /etc/apt/keyrings
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||||
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
```
|
||||||
|
|
||||||
|
Se deconnecter/reconnecter pour que le groupe `docker` prenne effet.
|
||||||
|
|
||||||
|
### Nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y nginx
|
||||||
|
sudo systemctl enable nginx
|
||||||
|
sudo systemctl start nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
|
||||||
|
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
|
||||||
|
Il doit etre installe et accessible avant de deployer Lesstime.
|
||||||
|
|
||||||
|
Creer la base de donnees pour Lesstime :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/postgres
|
||||||
|
docker compose exec postgres psql -U admin
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Si le user n'existe pas encore
|
||||||
|
CREATE USER malio WITH PASSWORD 'motdepasse';
|
||||||
|
|
||||||
|
-- Creer la base
|
||||||
|
CREATE DATABASE lesstime_prod OWNER malio;
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Premiere installation (nouvelle machine)
|
||||||
|
|
||||||
|
Guide complet pour mettre en ligne Lesstime sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
|
||||||
|
|
||||||
|
### 1. Installer les pre-requis
|
||||||
|
|
||||||
|
Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
|
||||||
|
|
||||||
|
### 2. Creer le dossier de deploiement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /var/www/lesstime
|
||||||
|
sudo chown -R $(whoami):$(whoami) /var/www/lesstime
|
||||||
|
cd /var/www/lesstime
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Se connecter au registry Docker de Gitea
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker login gitea.malio.fr
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Username** : le nom d'utilisateur du compte organisation Gitea `MALIO`
|
||||||
|
- **Password** : le token REGISTRY_TOKEN dispo dans le bitwarden
|
||||||
|
|
||||||
|
Le login est sauvegarde dans `~/.docker/config.json`, pas besoin de le refaire a chaque deploiement.
|
||||||
|
|
||||||
|
### 4. Creer les fichiers de deploiement
|
||||||
|
|
||||||
|
Creer `docker-compose.yml` :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: gitea.malio.fr/malio-dev/lesstime:${LESSTIME_IMAGE_TAG:-latest}
|
||||||
|
container_name: lesstime-app
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- ./config/jwt:/var/www/html/config/jwt:ro
|
||||||
|
- ./uploads:/var/www/html/var/uploads
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
Creer `deploy.sh` :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
TAG="${1:-latest}"
|
||||||
|
export LESSTIME_IMAGE_TAG="$TAG"
|
||||||
|
|
||||||
|
echo "==> Deploying lesstime:${TAG}..."
|
||||||
|
|
||||||
|
echo "==> Enabling maintenance mode..."
|
||||||
|
touch maintenance.on
|
||||||
|
|
||||||
|
echo "==> Pulling image..."
|
||||||
|
sudo docker compose pull
|
||||||
|
|
||||||
|
echo "==> Starting container..."
|
||||||
|
sudo docker compose up -d
|
||||||
|
|
||||||
|
echo "==> Waiting for container to be ready..."
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
echo "==> Extracting maintenance page..."
|
||||||
|
mkdir -p public
|
||||||
|
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||||
|
|
||||||
|
echo "==> Running migrations..."
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
|
echo "==> Clearing cache..."
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||||
|
|
||||||
|
echo "==> Disabling maintenance mode..."
|
||||||
|
rm -f maintenance.on
|
||||||
|
|
||||||
|
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||||
|
echo "==> Deployed v${VERSION}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendre executable :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Configurer l'environnement
|
||||||
|
|
||||||
|
Creer `.env` avec les variables suivantes :
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Symfony
|
||||||
|
APP_ENV=prod
|
||||||
|
APP_DEBUG=0
|
||||||
|
APP_SECRET=<generer avec: openssl rand -hex 32>
|
||||||
|
|
||||||
|
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
|
||||||
|
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/lesstime_prod?serverVersion=16&charset=utf8"
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||||
|
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||||
|
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
|
||||||
|
JWT_COOKIE_SECURE=1
|
||||||
|
JWT_COOKIE_SAMESITE=lax
|
||||||
|
JWT_TOKEN_TTL=86400
|
||||||
|
JWT_COOKIE_TTL=86400
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
|
||||||
|
|
||||||
|
# App
|
||||||
|
DEFAULT_URI=https://project.malio-dev.fr
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Generer les cles JWT
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p config/jwt
|
||||||
|
openssl genpkey -algorithm RSA -out config/jwt/private.pem -pkeyopt rsa_keygen_bits:4096
|
||||||
|
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
Rendre les cles lisibles par le conteneur (www-data = uid 33) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown 33:33 config/jwt/private.pem config/jwt/public.pem
|
||||||
|
sudo chmod 644 config/jwt/private.pem config/jwt/public.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Creer le dossier uploads
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Configurer Nginx systeme
|
||||||
|
|
||||||
|
Creer `/etc/nginx/sites-available/lesstime.conf` :
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name project.malio-dev.fr;
|
||||||
|
|
||||||
|
root /var/www/lesstime/public;
|
||||||
|
|
||||||
|
# Maintenance mode
|
||||||
|
if (-f /var/www/lesstime/maintenance.on) {
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 503 @maintenance;
|
||||||
|
|
||||||
|
location @maintenance {
|
||||||
|
rewrite ^(.*)$ /maintenance.html break;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /maintenance.html {
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 55m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Activer le site :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/lesstime.conf /etc/nginx/sites-enabled/lesstime.conf
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Deployer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. Importer les donnees (optionnel)
|
||||||
|
|
||||||
|
Si tu as un dump SQL a importer :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Depuis ton PC, envoyer le dump vers le serveur
|
||||||
|
scp lesstime.sql user@serveur:/tmp/lesstime.sql
|
||||||
|
|
||||||
|
# Sur le serveur, vider la base puis importer
|
||||||
|
cd /var/www/postgres
|
||||||
|
docker compose exec -T postgres psql -U malio lesstime_prod -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||||
|
docker compose exec -T postgres psql -U malio lesstime_prod < /tmp/lesstime.sql
|
||||||
|
|
||||||
|
# Creer les tables manquantes (si le dump a des erreurs de syntaxe)
|
||||||
|
cd /var/www/lesstime
|
||||||
|
docker compose exec -u www-data app php bin/console doctrine:schema:update --force --env=prod
|
||||||
|
|
||||||
|
# Nettoyer
|
||||||
|
rm /tmp/lesstime.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Structure finale du dossier
|
||||||
|
|
||||||
|
```
|
||||||
|
/var/www/lesstime/
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── deploy.sh
|
||||||
|
├── .env
|
||||||
|
├── config/jwt/
|
||||||
|
│ ├── private.pem
|
||||||
|
│ └── public.pem
|
||||||
|
├── public/
|
||||||
|
│ └── maintenance.html # extrait automatiquement par deploy.sh
|
||||||
|
└── uploads/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployer une nouvelle version
|
||||||
|
|
||||||
|
Quand l'app est deja installee, deployer une mise a jour :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/lesstime
|
||||||
|
./deploy.sh # deploie la derniere version (latest)
|
||||||
|
./deploy.sh v0.3.13 # deploie une version specifique
|
||||||
|
```
|
||||||
|
|
||||||
|
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
### Image seule (pas de changement de schema BDD)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy.sh v0.3.12
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avec rollback de migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Rollback schema (pendant que la version actuelle tourne encore)
|
||||||
|
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate prev --no-interaction
|
||||||
|
# 2. Deployer l'ancienne version
|
||||||
|
./deploy.sh v0.3.12
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
Le workflow `.gitea/workflows/build-docker.yml` se declenche automatiquement sur push de tag `v*` :
|
||||||
|
1. Build l'image multi-stage
|
||||||
|
2. Push vers `gitea.malio.fr/malio-dev/lesstime:<tag>` et `:latest`
|
||||||
|
|
||||||
|
Combine avec `auto-tag-develop.yml`, chaque push sur `develop` cree automatiquement un tag → build → image disponible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voir les logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/lesstime
|
||||||
|
docker compose logs -f # tous les logs
|
||||||
|
docker compose logs -f --tail=100 # 100 dernieres lignes
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs Symfony :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app cat var/log/prod.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration depuis l'ancien deploiement (bare-metal)
|
||||||
|
|
||||||
|
Si l'application tourne deja en bare metal :
|
||||||
|
|
||||||
|
1. Installer Docker (voir pre-requis)
|
||||||
|
2. Creer le dossier `/var/www/lesstime-docker/` (ne pas ecraser l'ancien)
|
||||||
|
3. Copier les fichiers existants :
|
||||||
|
```bash
|
||||||
|
cp /var/www/lesstime/.env /var/www/lesstime-docker/.env
|
||||||
|
cp -a /var/www/lesstime/config/jwt /var/www/lesstime-docker/config/jwt
|
||||||
|
cp -a /var/www/lesstime/var/uploads /var/www/lesstime-docker/uploads
|
||||||
|
```
|
||||||
|
4. Creer `docker-compose.yml` et `deploy.sh` dans `/var/www/lesstime-docker/` (voir etape 4 ci-dessus)
|
||||||
|
5. Editer `/var/www/lesstime-docker/.env` : changer `DATABASE_URL` pour utiliser `host.docker.internal` au lieu de `127.0.0.1`
|
||||||
|
6. Se connecter au registry Gitea (voir etape 3 ci-dessus)
|
||||||
|
7. Mettre a jour Nginx systeme avec la conf reverse proxy (voir etape 8 ci-dessus)
|
||||||
|
8. Arreter l'ancien PHP-FPM : `sudo systemctl stop php8.4-fpm`
|
||||||
|
9. Deployer : `cd /var/www/lesstime-docker && ./deploy.sh`
|
||||||
|
10. Verifier que tout marche, puis renommer le dossier : `mv /var/www/lesstime-docker /var/www/lesstime`
|
||||||
153
doc/setup-maintenance-mode.md
Normal file
153
doc/setup-maintenance-mode.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# Configuration du mode maintenance (nginx hote)
|
||||||
|
|
||||||
|
Guide pour activer le support du mode maintenance pilote par Central.
|
||||||
|
Ces etapes sont a faire **une seule fois** par application sur le serveur de production.
|
||||||
|
|
||||||
|
Le principe : le nginx de l'hote (reverse proxy) verifie si un fichier `maintenance.on` existe dans le dossier de deploy. Si oui, il sert une page `maintenance.html` au lieu de proxifier vers le container Docker.
|
||||||
|
|
||||||
|
Central pilote la creation/suppression de ce fichier via ses volumes Docker.
|
||||||
|
|
||||||
|
## Ce qui a ete fait pour Lesstime
|
||||||
|
|
||||||
|
### 1. Deployer pour extraire la page maintenance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/lesstime
|
||||||
|
sudo ./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `deploy.sh` extrait automatiquement `maintenance.html` du container vers `public/` :
|
||||||
|
```
|
||||||
|
mkdir -p public
|
||||||
|
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Mettre a jour la conf nginx de l'hote
|
||||||
|
|
||||||
|
Remplacer le contenu de `/etc/nginx/sites-available/lesstime.conf` :
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name project.malio-dev.fr;
|
||||||
|
|
||||||
|
root /var/www/lesstime/public;
|
||||||
|
|
||||||
|
# Maintenance mode
|
||||||
|
if (-f /var/www/lesstime/maintenance.on) {
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 503 @maintenance;
|
||||||
|
|
||||||
|
location @maintenance {
|
||||||
|
rewrite ^(.*)$ /maintenance.html break;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /maintenance.html {
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 55m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Recharger nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Verifier
|
||||||
|
|
||||||
|
- Depuis Central, activer la maintenance sur Lesstime
|
||||||
|
- Ouvrir `http://project.malio-dev.fr` → doit afficher la page "Maintenance en cours"
|
||||||
|
- Desactiver la maintenance depuis Central → le site revient
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A faire pour Inventory
|
||||||
|
|
||||||
|
Meme procedure :
|
||||||
|
|
||||||
|
### 1. Deployer pour extraire la page maintenance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/inventory
|
||||||
|
sudo ./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> Si le `deploy.sh` ne contient pas encore l'extraction, mettre a jour le fichier depuis le repo (`infra/prod/deploy.sh`) ou executer manuellement :
|
||||||
|
> ```bash
|
||||||
|
> mkdir -p public
|
||||||
|
> sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### 2. Mettre a jour la conf nginx de l'hote
|
||||||
|
|
||||||
|
Remplacer le contenu de `/etc/nginx/sites-available/inventory.conf` :
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name inventory.malio-dev.fr;
|
||||||
|
|
||||||
|
root /var/www/inventory/public;
|
||||||
|
|
||||||
|
# Maintenance mode
|
||||||
|
if (-f /var/www/inventory/maintenance.on) {
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 503 @maintenance;
|
||||||
|
|
||||||
|
location @maintenance {
|
||||||
|
rewrite ^(.*)$ /maintenance.html break;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /maintenance.html {
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8082;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Recharger nginx
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fonctionnement
|
||||||
|
|
||||||
|
```
|
||||||
|
Central (container)
|
||||||
|
└── touch /var/www/maintenance/lesstime/maintenance.on
|
||||||
|
│ (volume Docker : /var/www/lesstime → /var/www/maintenance/lesstime)
|
||||||
|
▼
|
||||||
|
/var/www/lesstime/maintenance.on (hote)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
nginx hote : if (-f /var/www/lesstime/maintenance.on) → 503
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
maintenance.html servie depuis /var/www/lesstime/public/
|
||||||
|
```
|
||||||
@@ -2,7 +2,7 @@ services:
|
|||||||
php:
|
php:
|
||||||
container_name: php-${DOCKER_APP_NAME}-fpm
|
container_name: php-${DOCKER_APP_NAME}-fpm
|
||||||
build:
|
build:
|
||||||
context: ./docker/php
|
context: ./infra/dev
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
|
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
|
||||||
@@ -21,8 +21,8 @@ services:
|
|||||||
- ~/.cache:/var/www/.cache # Pour la cache de composer
|
- ~/.cache:/var/www/.cache # Pour la cache de composer
|
||||||
- ~/.config:/var/www/.config # Pour la config de yarn
|
- ~/.config:/var/www/.config # Pour la config de yarn
|
||||||
- ~/.composer:/var/www/.composer # Pour la config de composer
|
- ~/.composer:/var/www/.composer # Pour la config de composer
|
||||||
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
|
- ./infra/dev/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
|
- ./infra/dev/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
|
- uploads_data:/var/www/html/var/uploads
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
- "8082:80"
|
- "8082:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/var/www/html:ro
|
- ./:/var/www/html:ro
|
||||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
|
- ./infra/dev/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
|||||||
87
docs/claude-time-tracking-rule.md
Normal file
87
docs/claude-time-tracking-rule.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Règle Claude : Time Tracking automatique via Lesstime
|
||||||
|
|
||||||
|
> Ajouter ce contenu dans le CLAUDE.md de chaque projet ou dans `~/.claude/CLAUDE.md` pour l'appliquer globalement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Time Tracking obligatoire
|
||||||
|
|
||||||
|
Claude DOIT créer une time entry dans Lesstime au démarrage de chaque tâche de développement, ou sur demande explicite de l'utilisateur ("lance le chrono", "start timer", "track le temps").
|
||||||
|
|
||||||
|
### Déclencheurs
|
||||||
|
|
||||||
|
1. **Début d'une tâche de dev** : feature, bugfix, refactoring, infra, review
|
||||||
|
2. **Demande explicite** : "lance le chrono", "start timer", "track le temps"
|
||||||
|
3. **Depuis un ticket Lesstime** : lier directement au taskId du ticket référencé
|
||||||
|
|
||||||
|
### Méthode
|
||||||
|
|
||||||
|
Créer la time entry via **curl** sur l'API REST Lesstime :
|
||||||
|
|
||||||
|
1. **Login** : `POST http://project.malio-dev.fr/api/login_check`
|
||||||
|
- Body : `{"username":"admin","password":"admin"}`
|
||||||
|
- Réponse : 204 avec cookie `Set-Cookie: BEARER=<jwt>`
|
||||||
|
|
||||||
|
2. **Créer le timer** : `POST http://project.malio-dev.fr/api/time_entries`
|
||||||
|
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/ld+json`, `Accept: application/ld+json`
|
||||||
|
- Body :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user": "/api/users/5",
|
||||||
|
"startedAt": "<ISO8601 avec timezone>",
|
||||||
|
"title": "<description courte de la tâche>",
|
||||||
|
"project": "/api/projects/<projectId>",
|
||||||
|
"tags": ["/api/task_tags/<tagId>"],
|
||||||
|
"task": "/api/tasks/<taskId>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Stopper le timer** : `PATCH http://project.malio-dev.fr/api/time_entries/<id>`
|
||||||
|
- Headers : `Cookie: BEARER=<jwt>`, `Content-Type: application/merge-patch+json`, `Accept: application/ld+json`
|
||||||
|
- Body : `{"stoppedAt": "<ISO8601>"}`
|
||||||
|
|
||||||
|
### Paramètres obligatoires
|
||||||
|
|
||||||
|
- **user** : TOUJOURS `/api/users/5` (Matthieu)
|
||||||
|
- **startedAt** : ISO 8601 avec timezone (ex: `2026-04-01T14:30:00+02:00`)
|
||||||
|
- **title** : description courte de la tâche en cours
|
||||||
|
- **project** : selon le projet (voir mapping ci-dessous)
|
||||||
|
|
||||||
|
### Tags (choisir selon le type de travail)
|
||||||
|
|
||||||
|
| Tag | ID | IRI |
|
||||||
|
|-----|----|-----|
|
||||||
|
| Backend | 3 | `/api/task_tags/3` |
|
||||||
|
| Frontend | 2 | `/api/task_tags/2` |
|
||||||
|
| IA | 7 | `/api/task_tags/7` |
|
||||||
|
| Infra | 5 | `/api/task_tags/5` |
|
||||||
|
| UI/UX | 4 | `/api/task_tags/4` |
|
||||||
|
| Maintenance | 6 | `/api/task_tags/6` |
|
||||||
|
| RDV | 1 | `/api/task_tags/1` |
|
||||||
|
| Réunion | 8 | `/api/task_tags/8` |
|
||||||
|
| Formation | 10 | `/api/task_tags/10` |
|
||||||
|
| Gestion projet | 9 | `/api/task_tags/9` |
|
||||||
|
|
||||||
|
### Mapping projets
|
||||||
|
|
||||||
|
| Projet | ID | IRI |
|
||||||
|
|--------|----|-----|
|
||||||
|
| Lesstime | 5 | `/api/projects/5` |
|
||||||
|
| Inventory | 7 | `/api/projects/7` |
|
||||||
|
| SIRH | 12 | `/api/projects/12` |
|
||||||
|
| Infrastructure | 13 | `/api/projects/13` |
|
||||||
|
| Malio UI | 11 | `/api/projects/11` |
|
||||||
|
| ERP Liot | 6 | `/api/projects/6` |
|
||||||
|
| Ferme | 8 | `/api/projects/8` |
|
||||||
|
| ADMIN | 16 | `/api/projects/16` |
|
||||||
|
| Maintenance-LIOT | 17 | `/api/projects/17` |
|
||||||
|
| Qualiopi | 14 | `/api/projects/14` |
|
||||||
|
| Vaultwarden | 18 | `/api/projects/18` |
|
||||||
|
|
||||||
|
### Règles
|
||||||
|
|
||||||
|
- **Un seul timer actif à la fois** (contrainte DB) — stopper l'actif avant d'en créer un nouveau
|
||||||
|
- **Toujours stopper le timer** en fin de tâche ou sur demande
|
||||||
|
- **Informer l'utilisateur** quand un timer est lancé/stoppé (numéro, titre, projet, tags)
|
||||||
|
- **Lier au ticket Lesstime** si un ticket est référencé (champ `task`)
|
||||||
|
- **Choisir les tags intelligemment** selon le type de travail effectué
|
||||||
@@ -61,7 +61,7 @@ ENCRYPTION_KEY=<random-hex-32>
|
|||||||
## 4. Installer le script de deploy
|
## 4. Installer le script de deploy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp script/deploy-release.sh /usr/local/bin/deploy-lesstime
|
sudo cp infra/prod/deploy-release.sh /usr/local/bin/deploy-lesstime
|
||||||
sudo chmod +x /usr/local/bin/deploy-lesstime
|
sudo chmod +x /usr/local/bin/deploy-lesstime
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ sudo -u www-data php bin/console lexik:jwt:generate-keypair --skip-if-exists --e
|
|||||||
## 7. Configurer Nginx
|
## 7. Configurer Nginx
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp deploy/nginx/lesstime.conf /etc/nginx/sites-available/lesstime
|
sudo cp infra/prod/nginx-baremetal.conf /etc/nginx/sites-available/lesstime
|
||||||
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
|
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -10,21 +10,17 @@
|
|||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MalioInputText
|
<MalioInputPassword
|
||||||
v-model="form.tokenId"
|
v-model="form.tokenId"
|
||||||
:label="$t('bookstack.settings.tokenId')"
|
:label="$t('bookstack.settings.tokenId')"
|
||||||
:placeholder="$t('bookstack.settings.tokenIdPlaceholder')"
|
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
type="password"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MalioInputText
|
<MalioInputPassword
|
||||||
v-model="form.tokenSecret"
|
v-model="form.tokenSecret"
|
||||||
:label="$t('bookstack.settings.tokenSecret')"
|
:label="$t('bookstack.settings.tokenSecret')"
|
||||||
:placeholder="$t('bookstack.settings.tokenSecretPlaceholder')"
|
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
type="password"
|
|
||||||
/>
|
/>
|
||||||
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
|
<p v-if="hasToken && !form.tokenId && !form.tokenSecret" class="mt-1 text-xs text-green-600">
|
||||||
{{ $t('bookstack.settings.tokenConfigured') }}
|
{{ $t('bookstack.settings.tokenConfigured') }}
|
||||||
|
|||||||
@@ -11,12 +11,10 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<MalioInputText
|
<MalioInputPassword
|
||||||
v-model="form.token"
|
v-model="form.token"
|
||||||
:label="$t('gitea.settings.token')"
|
:label="$t('gitea.settings.token')"
|
||||||
:placeholder="$t('gitea.settings.tokenPlaceholder')"
|
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
type="password"
|
|
||||||
/>
|
/>
|
||||||
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
|
<p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
|
||||||
{{ $t('gitea.settings.tokenConfigured') }}
|
{{ $t('gitea.settings.tokenConfigured') }}
|
||||||
|
|||||||
@@ -22,11 +22,10 @@
|
|||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<MalioInputText
|
<MalioInputPassword
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
:label="$t('zimbra.settings.password')"
|
:label="$t('zimbra.settings.password')"
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
type="password"
|
|
||||||
/>
|
/>
|
||||||
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
<p v-if="hasPassword && !form.password" class="mt-1 text-xs text-green-600">
|
||||||
{{ $t('zimbra.settings.passwordConfigured') }}
|
{{ $t('zimbra.settings.passwordConfigured') }}
|
||||||
|
|||||||
@@ -66,14 +66,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
<MalioInputRichText
|
||||||
{{ $t('clientTicket.description') }}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-model="editForm.description"
|
v-model="editForm.description"
|
||||||
rows="5"
|
:label="$t('clientTicket.description')"
|
||||||
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
min-height="180px"
|
||||||
style="resize: vertical; min-height: 140px; max-height: 500px"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,7 +125,13 @@
|
|||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
||||||
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
|
<MalioInputRichText
|
||||||
|
v-if="ticket.description"
|
||||||
|
:model-value="ticket.description"
|
||||||
|
:editable="false"
|
||||||
|
group-class="mt-1"
|
||||||
|
/>
|
||||||
|
<p v-else class="mt-1 text-sm italic text-neutral-400">—</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- URL (if bug) -->
|
<!-- URL (if bug) -->
|
||||||
|
|||||||
@@ -116,7 +116,12 @@
|
|||||||
|
|
||||||
<!-- Expanded details -->
|
<!-- Expanded details -->
|
||||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
|
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
|
||||||
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
<MalioInputRichText
|
||||||
|
v-if="ticket.description"
|
||||||
|
:model-value="ticket.description"
|
||||||
|
:editable="false"
|
||||||
|
/>
|
||||||
|
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||||
<div v-if="ticket.url" class="mt-2">
|
<div v-if="ticket.url" class="mt-2">
|
||||||
<a
|
<a
|
||||||
:href="ticket.url"
|
:href="ticket.url"
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-description="{ item }">
|
<template #cell-description="{ item }">
|
||||||
{{ item.description ?? '—' }}
|
{{ stripRichText(item.description) || '—' }}
|
||||||
</template>
|
</template>
|
||||||
<template #actions="{ item }">
|
<template #actions="{ item }">
|
||||||
<MalioButton
|
<MalioButton
|
||||||
@@ -71,6 +71,7 @@ import type { TaskGroup } from '~/services/dto/task-group'
|
|||||||
import type { Task } from '~/services/dto/task'
|
import type { Task } from '~/services/dto/task'
|
||||||
import { useTaskGroupService } from '~/services/task-groups'
|
import { useTaskGroupService } from '~/services/task-groups'
|
||||||
import { useTaskService } from '~/services/tasks'
|
import { useTaskService } from '~/services/tasks'
|
||||||
|
import { stripRichText } from '~/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
projectId: number
|
projectId: number
|
||||||
|
|||||||
@@ -78,11 +78,17 @@
|
|||||||
class="text-blue-500"
|
class="text-blue-500"
|
||||||
size="14"
|
size="14"
|
||||||
/>
|
/>
|
||||||
|
<Icon
|
||||||
|
v-if="task.collaborators?.length"
|
||||||
|
name="mdi:account-group"
|
||||||
|
class="ml-auto h-4 w-4 text-neutral-400"
|
||||||
|
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||||
|
/>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
v-if="task.assignee"
|
v-if="task.assignee"
|
||||||
:user="task.assignee"
|
:user="task.assignee"
|
||||||
size="xs"
|
size="xs"
|
||||||
class="ml-auto"
|
:class="task.collaborators?.length ? '' : 'ml-auto'"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
:error="touched.title && !form.title.trim() ? 'Le titre est requis' : ''"
|
||||||
@blur="touched.title = true"
|
@blur="touched.title = true"
|
||||||
/>
|
/>
|
||||||
<MalioInputTextArea
|
<MalioInputRichText
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
label="Description"
|
label="Description"
|
||||||
:size="3"
|
min-height="120px"
|
||||||
/>
|
/>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<ColorPicker v-model="form.color" />
|
<ColorPicker v-model="form.color" />
|
||||||
|
|||||||
@@ -86,17 +86,25 @@
|
|||||||
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
|
:button-class="isTimerOnTask ? 'shrink-0 text-[#F18619] hover:text-[#d97314]' : 'shrink-0 text-neutral-400 hover:text-primary-500'"
|
||||||
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
|
@click.stop="isTimerOnTask ? timerStore.stop() : timerStore.startFromTask(task)"
|
||||||
/>
|
/>
|
||||||
<UserAvatar
|
<div class="flex items-center gap-1">
|
||||||
v-if="task.assignee"
|
<Icon
|
||||||
:user="task.assignee"
|
v-if="task.collaborators?.length"
|
||||||
size="xs"
|
name="mdi:account-group"
|
||||||
/>
|
class="h-4 w-4 text-neutral-400"
|
||||||
<span
|
:title="task.collaborators.map(c => c.username).join(', ')"
|
||||||
v-else
|
/>
|
||||||
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
<UserAvatar
|
||||||
>
|
v-if="task.assignee"
|
||||||
<Icon name="mdi:account-outline" size="14" />
|
:user="task.assignee"
|
||||||
</span>
|
size="xs"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-neutral-400"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:account-outline" size="14" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -170,15 +170,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Collaborators -->
|
||||||
|
<div v-if="collaboratorOptions.length" class="mt-5">
|
||||||
|
<p class="mb-2 text-sm font-medium text-neutral-700">Collaborateurs</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<label
|
||||||
|
v-for="user in collaboratorOptions"
|
||||||
|
:key="user.value"
|
||||||
|
class="cursor-pointer rounded-full px-3 py-1 text-xs font-semibold transition-all"
|
||||||
|
:class="form.collaboratorIds.includes(user.value)
|
||||||
|
? 'bg-primary-500 text-white shadow-sm'
|
||||||
|
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="hidden"
|
||||||
|
:value="user.value"
|
||||||
|
:checked="form.collaboratorIds.includes(user.value)"
|
||||||
|
@change="toggleCollaborator(user.value)"
|
||||||
|
/>
|
||||||
|
{{ user.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<MalioInputTextArea
|
<MalioInputRichText
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
label="Description"
|
label="Description"
|
||||||
:size="5"
|
min-height="180px"
|
||||||
resize="vertical"
|
|
||||||
:min-resize-height="140"
|
|
||||||
:max-resize-height="500"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -544,6 +565,7 @@ const form = reactive({
|
|||||||
effortId: null as number | null,
|
effortId: null as number | null,
|
||||||
priorityId: null as number | null,
|
priorityId: null as number | null,
|
||||||
assigneeId: null as number | null,
|
assigneeId: null as number | null,
|
||||||
|
collaboratorIds: [] as number[],
|
||||||
groupId: null as number | null,
|
groupId: null as number | null,
|
||||||
tagIds: [] as number[],
|
tagIds: [] as number[],
|
||||||
clientTicketId: null as number | null,
|
clientTicketId: null as number | null,
|
||||||
@@ -586,6 +608,18 @@ const userOptions = computed(() =>
|
|||||||
props.users.map(u => ({ label: u.username, value: u.id }))
|
props.users.map(u => ({ label: u.username, value: u.id }))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const collaboratorOptions = computed(() =>
|
||||||
|
props.users
|
||||||
|
.filter(u => u.id !== form.assigneeId)
|
||||||
|
.map(u => ({ label: u.username, value: u.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(() => form.assigneeId, (newAssigneeId) => {
|
||||||
|
if (newAssigneeId) {
|
||||||
|
form.collaboratorIds = form.collaboratorIds.filter(id => id !== newAssigneeId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const groupOptions = computed(() => {
|
const groupOptions = computed(() => {
|
||||||
let filtered = props.groups.filter(g => !g.archived)
|
let filtered = props.groups.filter(g => !g.archived)
|
||||||
if (showProjectSelect.value && form.projectId) {
|
if (showProjectSelect.value && form.projectId) {
|
||||||
@@ -624,6 +658,12 @@ function toggleTag(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCollaborator(userId: number) {
|
||||||
|
const idx = form.collaboratorIds.indexOf(userId)
|
||||||
|
if (idx >= 0) form.collaboratorIds.splice(idx, 1)
|
||||||
|
else form.collaboratorIds.push(userId)
|
||||||
|
}
|
||||||
|
|
||||||
const weekDays = computed(() => [
|
const weekDays = computed(() => [
|
||||||
{ value: 'monday', label: t('tasks.planning.days.mon') },
|
{ value: 'monday', label: t('tasks.planning.days.mon') },
|
||||||
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
|
{ value: 'tuesday', label: t('tasks.planning.days.tue') },
|
||||||
@@ -648,6 +688,7 @@ function populateForm(task: Task | null) {
|
|||||||
form.effortId = task.effort?.id ?? null
|
form.effortId = task.effort?.id ?? null
|
||||||
form.priorityId = task.priority?.id ?? null
|
form.priorityId = task.priority?.id ?? null
|
||||||
form.assigneeId = task.assignee?.id ?? null
|
form.assigneeId = task.assignee?.id ?? null
|
||||||
|
form.collaboratorIds = task.collaborators?.map(c => c.id) ?? []
|
||||||
form.groupId = task.group?.id ?? null
|
form.groupId = task.group?.id ?? null
|
||||||
form.tagIds = task.tags.map(t => t.id)
|
form.tagIds = task.tags.map(t => t.id)
|
||||||
form.clientTicketId = task.clientTicket?.id ?? null
|
form.clientTicketId = task.clientTicket?.id ?? null
|
||||||
@@ -694,6 +735,7 @@ function populateForm(task: Task | null) {
|
|||||||
form.effortId = null
|
form.effortId = null
|
||||||
form.priorityId = null
|
form.priorityId = null
|
||||||
form.assigneeId = null
|
form.assigneeId = null
|
||||||
|
form.collaboratorIds = []
|
||||||
form.groupId = null
|
form.groupId = null
|
||||||
form.tagIds = []
|
form.tagIds = []
|
||||||
form.clientTicketId = null
|
form.clientTicketId = null
|
||||||
@@ -906,6 +948,7 @@ async function handleSubmit() {
|
|||||||
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
|
effort: form.effortId ? `/api/task_efforts/${form.effortId}` : null,
|
||||||
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
priority: form.priorityId ? `/api/task_priorities/${form.priorityId}` : null,
|
||||||
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
assignee: form.assigneeId ? `/api/users/${form.assigneeId}` : null,
|
||||||
|
collaborators: form.collaboratorIds.map(id => `/api/users/${id}`),
|
||||||
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
group: form.groupId ? `/api/task_groups/${form.groupId}` : null,
|
||||||
project: `/api/projects/${resolvedProjectId.value}`,
|
project: `/api/projects/${resolvedProjectId.value}`,
|
||||||
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
tags: form.tagIds.map(id => `/api/task_tags/${id}`),
|
||||||
|
|||||||
@@ -11,14 +11,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<MalioInputRichText
|
||||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Description</label>
|
v-model="form.description"
|
||||||
<textarea
|
label="Description"
|
||||||
v-model="form.description"
|
min-height="120px"
|
||||||
rows="3"
|
/>
|
||||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
|
<label class="mb-1 block text-sm font-semibold text-neutral-700">Date</label>
|
||||||
|
|||||||
@@ -33,8 +33,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
<div class="mt-0.5 flex items-center gap-2 text-xs text-neutral-500">
|
||||||
<span v-if="entry.project">{{ entry.project.name }}</span>
|
<span v-if="entry.project">{{ entry.project.name }}</span>
|
||||||
<span v-if="entry.project && entry.description" class="text-neutral-300">·</span>
|
<span v-if="entry.project && stripRichText(entry.description)" class="text-neutral-300">·</span>
|
||||||
<span v-if="entry.description" class="truncate">{{ entry.description }}</span>
|
<span v-if="stripRichText(entry.description)" class="truncate">{{ stripRichText(entry.description) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,6 +68,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TimeEntry } from '~/services/dto/time-entry'
|
import type { TimeEntry } from '~/services/dto/time-entry'
|
||||||
|
import { stripRichText } from '~/utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entries: TimeEntry[]
|
entries: TimeEntry[]
|
||||||
|
|||||||
@@ -10,15 +10,7 @@
|
|||||||
@click="ui.openMobileSidebar()"
|
@click="ui.openMobileSidebar()"
|
||||||
/>
|
/>
|
||||||
<div class="hidden items-center gap-2 lg:flex">
|
<div class="hidden items-center gap-2 lg:flex">
|
||||||
<h1 class="text-lg font-bold tracking-tight">{{ appTitle }}</h1>
|
<h1 class="text-lg font-bold tracking-tight">Lesstime</h1>
|
||||||
<MalioButtonIcon
|
|
||||||
icon="mdi:swap-horizontal"
|
|
||||||
:aria-label="appTitle === 'NeauTime' ? 'Switch to Lesstime' : 'Switch to NeauTime'"
|
|
||||||
variant="ghost"
|
|
||||||
icon-size="18"
|
|
||||||
button-class="text-white/60 hover:bg-primary-600 hover:text-white"
|
|
||||||
@click="toggleTitle"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
<div class="ml-auto flex items-center gap-4 text-xl text-white sm:gap-8">
|
||||||
<MalioButtonIcon
|
<MalioButtonIcon
|
||||||
@@ -66,13 +58,6 @@ defineProps<{
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
|
|
||||||
const appTitle = ref(localStorage.getItem('appTitle') || 'NeauTime')
|
|
||||||
|
|
||||||
function toggleTitle() {
|
|
||||||
appTitle.value = appTitle.value === 'NeauTime' ? 'Lesstime' : 'NeauTime'
|
|
||||||
localStorage.setItem('appTitle', appTitle.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
await navigateTo('/login')
|
await navigateTo('/login')
|
||||||
|
|||||||
75
frontend/components/ui/MarkdownPreviewModal.vue
Normal file
75
frontend/components/ui/MarkdownPreviewModal.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="md-preview" appear>
|
||||||
|
<div v-if="modelValue" class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div
|
||||||
|
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
||||||
|
style="max-height: min(80vh, 700px)"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||||
|
<h3 class="text-lg font-semibold text-slate-800">
|
||||||
|
{{ title }}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
class="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
>
|
||||||
|
<Icon name="heroicons:x-mark" class="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="overflow-y-auto px-6 py-4">
|
||||||
|
<div
|
||||||
|
v-if="content"
|
||||||
|
class="prose prose-slate max-w-none prose-headings:font-semibold prose-a:text-blue-600 prose-code:rounded prose-code:bg-slate-100 prose-code:px-1.5 prose-code:py-0.5 prose-code:text-sm prose-code:before:content-none prose-code:after:content-none prose-pre:bg-slate-900 prose-pre:text-slate-100 prose-pre:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-inherit [&_pre_code]:text-[0.875rem] [&_pre_code]:leading-relaxed"
|
||||||
|
v-html="renderedHtml"
|
||||||
|
/>
|
||||||
|
<p v-else class="text-sm italic text-slate-400">
|
||||||
|
Aucune description
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { marked } from 'marked'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
content: string
|
||||||
|
title?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const renderedHtml = computed(() => {
|
||||||
|
if (!props.content) return ''
|
||||||
|
return marked.parse(props.content, { async: false }) as string
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.md-preview-enter-active,
|
||||||
|
.md-preview-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-preview-enter-from,
|
||||||
|
.md-preview-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,12 +8,11 @@
|
|||||||
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
|
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
|
||||||
@blur="touched.username = true"
|
@blur="touched.username = true"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputPassword
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
label="Mot de passe"
|
label="Mot de passe"
|
||||||
input-class="w-full"
|
input-class="w-full"
|
||||||
type="password"
|
:hint="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
||||||
:placeholder="isEditing ? 'Laisser vide pour ne pas changer' : ''"
|
|
||||||
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
|
:error="touched.password && !isEditing && !form.password ? 'Le mot de passe est requis' : ''"
|
||||||
@blur="touched.password = true"
|
@blur="touched.password = true"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -393,7 +393,21 @@
|
|||||||
"title": "Mon profil",
|
"title": "Mon profil",
|
||||||
"changeAvatar": "Changer l'avatar",
|
"changeAvatar": "Changer l'avatar",
|
||||||
"removeAvatar": "Supprimer l'avatar",
|
"removeAvatar": "Supprimer l'avatar",
|
||||||
"cropAvatar": "Recadrer l'avatar"
|
"cropAvatar": "Recadrer l'avatar",
|
||||||
|
"apiToken": {
|
||||||
|
"title": "Token API MCP",
|
||||||
|
"help": "Utilisé pour authentifier le serveur MCP HTTP (à coller dans le header Authorization: Bearer …). Ne pas partager.",
|
||||||
|
"label": "Token",
|
||||||
|
"empty": "Aucun token généré pour le moment.",
|
||||||
|
"generate": "Générer un token",
|
||||||
|
"regenerate": "Régénérer",
|
||||||
|
"copy": "Copier",
|
||||||
|
"copied": "Token copié dans le presse-papiers.",
|
||||||
|
"copyFailed": "Impossible de copier le token.",
|
||||||
|
"regenerated": "Nouveau token généré. L'ancien token est désormais invalide.",
|
||||||
|
"confirmTitle": "Régénérer le token MCP ?",
|
||||||
|
"confirmMessage": "L'ancien token sera immédiatement invalidé. Tous les clients MCP utilisant ce token devront être reconfigurés."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"bookstack": {
|
"bookstack": {
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['markdown-it-task-lists'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
toast: {
|
toast: {
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
921
frontend/package-lock.json
generated
921
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,13 +11,15 @@
|
|||||||
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@malio/layer-ui": "^1.2.0",
|
"@malio/layer-ui": "^1.4.8",
|
||||||
"@nuxt/icon": "^2.2.1",
|
"@nuxt/icon": "^2.2.1",
|
||||||
"@nuxtjs/i18n": "^10.2.3",
|
"@nuxtjs/i18n": "^10.2.3",
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
"@pinia/nuxt": "^0.11.3",
|
"@pinia/nuxt": "^0.11.3",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@vuepic/vue-datepicker": "^12.1.0",
|
"@vuepic/vue-datepicker": "^12.1.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"marked": "^18.0.0",
|
||||||
"nuxt": "^4.3.1",
|
"nuxt": "^4.3.1",
|
||||||
"nuxt-toast": "^1.4.0",
|
"nuxt-toast": "^1.4.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
|||||||
@@ -93,11 +93,22 @@ const isWeekPeriod = computed(() =>
|
|||||||
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
|
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Filtered data (client-side project filter) ──
|
// ── Filtered data (client-side project + user filter) ──
|
||||||
|
|
||||||
|
const effectiveUserId = computed(() => selectedUserId.value ?? auth.user?.id ?? null)
|
||||||
|
|
||||||
const tasks = computed(() => {
|
const tasks = computed(() => {
|
||||||
if (!selectedProjectId.value) return allTasks.value
|
let result = allTasks.value
|
||||||
return allTasks.value.filter(t => t.project?.id === selectedProjectId.value)
|
if (selectedProjectId.value) {
|
||||||
|
result = result.filter(t => t.project?.id === selectedProjectId.value)
|
||||||
|
}
|
||||||
|
if (selectedUserId.value) {
|
||||||
|
result = result.filter(t =>
|
||||||
|
t.assignee?.id === selectedUserId.value
|
||||||
|
|| t.collaborators?.some(c => c.id === selectedUserId.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
const timeEntries = computed(() => {
|
const timeEntries = computed(() => {
|
||||||
@@ -172,7 +183,10 @@ const totalHoursThisWeek = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const myTasks = computed(() =>
|
const myTasks = computed(() =>
|
||||||
tasks.value.filter(t => t.assignee?.id === auth.user?.id)
|
tasks.value.filter(t =>
|
||||||
|
t.assignee?.id === effectiveUserId.value
|
||||||
|
|| t.collaborators?.some(c => c.id === effectiveUserId.value),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
const myTasksDone = computed(() =>
|
const myTasksDone = computed(() =>
|
||||||
|
|||||||
@@ -17,24 +17,18 @@
|
|||||||
v-model="username"
|
v-model="username"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<MalioInputPassword
|
||||||
<label class="text-sm font-semibold text-neutral-700" for="password">
|
v-model="password"
|
||||||
Mot de passe
|
label="Mot de passe"
|
||||||
</label>
|
autocomplete="current-password"
|
||||||
<input
|
input-class="w-full"
|
||||||
id="password"
|
/>
|
||||||
v-model="password"
|
|
||||||
type="password"
|
|
||||||
autocomplete="current-password"
|
|
||||||
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MalioButton
|
<MalioButton
|
||||||
label="Se connecter"
|
label="Se connecter"
|
||||||
button-class="w-full"
|
button-class="w-full"
|
||||||
|
type="submit"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="handleSubmit"
|
|
||||||
/>
|
/>
|
||||||
<p class="font-bold">v{{ version }}</p>
|
<p class="font-bold">v{{ version }}</p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ const selectedEffortId = ref<number | null>(null)
|
|||||||
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
const selectedAssigneeId = ref<number | null>(auth.user?.id ?? null)
|
||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
type SortOption = 'default' | 'deadline' | 'scheduledStart'
|
const SORT_DEADLINE = 1
|
||||||
const sortBy = ref<SortOption>('default')
|
const SORT_SCHEDULED = 2
|
||||||
|
const sortById = ref<number | null>(null)
|
||||||
|
|
||||||
// View toggle
|
// View toggle
|
||||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||||
@@ -106,6 +107,11 @@ const assigneeOptions = computed(() =>
|
|||||||
users.value.map(u => ({ label: u.username, value: u.id }))
|
users.value.map(u => ({ label: u.username, value: u.id }))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const sortOptions = computed(() => [
|
||||||
|
{ label: t('myTasks.sortDeadline'), value: SORT_DEADLINE },
|
||||||
|
{ label: t('myTasks.sortScheduledStart'), value: SORT_SCHEDULED },
|
||||||
|
])
|
||||||
|
|
||||||
// Kanban helpers
|
// Kanban helpers
|
||||||
const sortedStatuses = computed(() =>
|
const sortedStatuses = computed(() =>
|
||||||
[...statuses.value].sort((a, b) => a.position - b.position)
|
[...statuses.value].sort((a, b) => a.position - b.position)
|
||||||
@@ -140,33 +146,43 @@ async function loadReferenceData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
const params: Record<string, string | number | boolean | string[]> = {
|
const baseParams: Record<string, string | number | boolean | string[]> = {
|
||||||
archived: false,
|
archived: false,
|
||||||
}
|
}
|
||||||
if (selectedAssigneeId.value) {
|
|
||||||
params.assignee = `/api/users/${selectedAssigneeId.value}`
|
|
||||||
}
|
|
||||||
if (selectedProjectId.value) {
|
if (selectedProjectId.value) {
|
||||||
params.project = `/api/projects/${selectedProjectId.value}`
|
baseParams.project = `/api/projects/${selectedProjectId.value}`
|
||||||
}
|
}
|
||||||
if (selectedGroupId.value) {
|
if (selectedGroupId.value) {
|
||||||
params.group = `/api/task_groups/${selectedGroupId.value}`
|
baseParams.group = `/api/task_groups/${selectedGroupId.value}`
|
||||||
}
|
}
|
||||||
if (selectedPriorityId.value) {
|
if (selectedPriorityId.value) {
|
||||||
params.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
baseParams.priority = `/api/task_priorities/${selectedPriorityId.value}`
|
||||||
}
|
}
|
||||||
if (selectedEffortId.value) {
|
if (selectedEffortId.value) {
|
||||||
params.effort = `/api/task_efforts/${selectedEffortId.value}`
|
baseParams.effort = `/api/task_efforts/${selectedEffortId.value}`
|
||||||
}
|
}
|
||||||
if (selectedTagId.value) {
|
if (selectedTagId.value) {
|
||||||
params['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
baseParams['tags[]'] = `/api/task_tags/${selectedTagId.value}`
|
||||||
}
|
}
|
||||||
if (sortBy.value === 'deadline') {
|
if (sortById.value === SORT_DEADLINE) {
|
||||||
params['order[deadline]'] = 'asc'
|
baseParams['order[deadline]'] = 'asc'
|
||||||
} else if (sortBy.value === 'scheduledStart') {
|
} else if (sortById.value === SORT_SCHEDULED) {
|
||||||
params['order[scheduledStart]'] = 'asc'
|
baseParams['order[scheduledStart]'] = 'asc'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedAssigneeId.value) {
|
||||||
|
const userIri = `/api/users/${selectedAssigneeId.value}`
|
||||||
|
const [assigneeTasks, collabTasks] = await Promise.all([
|
||||||
|
taskService.getFiltered({ ...baseParams, assignee: userIri }),
|
||||||
|
taskService.getFiltered({ ...baseParams, 'collaborators[]': userIri }),
|
||||||
|
])
|
||||||
|
const map = new Map<number, Task>()
|
||||||
|
for (const t of assigneeTasks) map.set(t.id, t)
|
||||||
|
for (const t of collabTasks) map.set(t.id, t)
|
||||||
|
tasks.value = [...map.values()].sort((a, b) => b.id - a.id)
|
||||||
|
} else {
|
||||||
|
tasks.value = await taskService.getFiltered(baseParams)
|
||||||
}
|
}
|
||||||
tasks.value = await taskService.getFiltered(params)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
@@ -180,7 +196,7 @@ async function loadAll() {
|
|||||||
|
|
||||||
// Watch filters and sort to reload tasks
|
// Watch filters and sort to reload tasks
|
||||||
watch(
|
watch(
|
||||||
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortBy],
|
[selectedProjectId, selectedGroupId, selectedTagId, selectedPriorityId, selectedEffortId, selectedAssigneeId, sortById],
|
||||||
() => { loadTasks() },
|
() => { loadTasks() },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -400,17 +416,15 @@ onMounted(async () => {
|
|||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-0.5">
|
<MalioSelect
|
||||||
<span class="text-xs font-semibold text-neutral-500">{{ $t('myTasks.sortBy') }}</span>
|
v-model="sortById"
|
||||||
<select
|
:options="sortOptions"
|
||||||
v-model="sortBy"
|
:label="$t('myTasks.sortBy')"
|
||||||
class="rounded-lg border border-neutral-300 bg-white px-2 py-1.5 text-sm text-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
:empty-option-label="$t('myTasks.sortDefault')"
|
||||||
>
|
min-width="!w-40"
|
||||||
<option value="default">{{ $t('myTasks.sortDefault') }}</option>
|
text-field="text-sm"
|
||||||
<option value="deadline">{{ $t('myTasks.sortDeadline') }}</option>
|
text-value="text-sm"
|
||||||
<option value="scheduledStart">{{ $t('myTasks.sortScheduledStart') }}</option>
|
/>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,15 +37,10 @@
|
|||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<MalioInputTextArea
|
<MalioInputRichText
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
:label="$t('clientTicket.description')"
|
:label="$t('clientTicket.description')"
|
||||||
:size="5"
|
min-height="180px"
|
||||||
resize="vertical"
|
|
||||||
:min-resize-height="140"
|
|
||||||
:max-resize-height="500"
|
|
||||||
min-resize-width="100%"
|
|
||||||
max-resize-width="100%"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- API Token MCP (interne uniquement) -->
|
||||||
|
<div
|
||||||
|
v-if="!isClientOnly"
|
||||||
|
class="mt-8 rounded-xl border border-neutral-200 bg-white p-6 shadow-sm"
|
||||||
|
>
|
||||||
|
<h2 class="mb-1 text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.title') }}</h2>
|
||||||
|
<p class="mb-4 text-sm text-neutral-600">{{ $t('profile.apiToken.help') }}</p>
|
||||||
|
|
||||||
|
<div v-if="auth.user?.apiToken">
|
||||||
|
<MalioInputPassword
|
||||||
|
:model-value="auth.user.apiToken"
|
||||||
|
:label="$t('profile.apiToken.label')"
|
||||||
|
readonly
|
||||||
|
@update:model-value="() => {}"
|
||||||
|
/>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-3">
|
||||||
|
<MalioButton
|
||||||
|
variant="secondary"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
icon-name="mdi:content-copy"
|
||||||
|
icon-position="left"
|
||||||
|
:label="$t('profile.apiToken.copy')"
|
||||||
|
@click="onCopy"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="danger"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
icon-name="mdi:refresh"
|
||||||
|
icon-position="left"
|
||||||
|
:disabled="regenerating"
|
||||||
|
:label="$t('profile.apiToken.regenerate')"
|
||||||
|
@click="showConfirm = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<p class="mb-4 text-sm text-neutral-500 italic">{{ $t('profile.apiToken.empty') }}</p>
|
||||||
|
<MalioButton
|
||||||
|
variant="primary"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
icon-name="mdi:key-plus"
|
||||||
|
icon-position="left"
|
||||||
|
:disabled="regenerating"
|
||||||
|
:label="$t('profile.apiToken.generate')"
|
||||||
|
@click="onRegenerate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Crop modal -->
|
<!-- Crop modal -->
|
||||||
<AvatarCropper
|
<AvatarCropper
|
||||||
v-if="selectedFile"
|
v-if="selectedFile"
|
||||||
@@ -44,14 +94,45 @@
|
|||||||
@crop="onCrop"
|
@crop="onCrop"
|
||||||
@cancel="selectedFile = null"
|
@cancel="selectedFile = null"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Confirm regenerate modal -->
|
||||||
|
<Teleport v-if="showConfirm" to="body">
|
||||||
|
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
||||||
|
<div class="absolute inset-0 bg-black/30" @click.stop="showConfirm = false" />
|
||||||
|
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||||
|
<h3 class="text-lg font-bold text-neutral-900">{{ $t('profile.apiToken.confirmTitle') }}</h3>
|
||||||
|
<p class="mt-3 text-sm text-neutral-600">
|
||||||
|
{{ $t('profile.apiToken.confirmMessage') }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
:label="$t('common.cancel')"
|
||||||
|
@click="showConfirm = false"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
variant="danger"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
:disabled="regenerating"
|
||||||
|
:label="$t('profile.apiToken.regenerate')"
|
||||||
|
@click="onRegenerate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAvatarService } from '~/composables/useAvatarService'
|
import { useAvatarService } from '~/composables/useAvatarService'
|
||||||
|
import { useApiTokenService } from '~/services/api-token'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const toast = useToast()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const isClientOnly = computed(() =>
|
const isClientOnly = computed(() =>
|
||||||
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN')
|
||||||
@@ -61,9 +142,12 @@ definePageMeta({
|
|||||||
layout: false,
|
layout: false,
|
||||||
})
|
})
|
||||||
const { upload, remove } = useAvatarService()
|
const { upload, remove } = useAvatarService()
|
||||||
|
const { regenerate } = useApiTokenService()
|
||||||
|
|
||||||
const selectedFile = ref<File | null>(null)
|
const selectedFile = ref<File | null>(null)
|
||||||
const removing = ref(false)
|
const removing = ref(false)
|
||||||
|
const regenerating = ref(false)
|
||||||
|
const showConfirm = ref(false)
|
||||||
|
|
||||||
function onFileSelect(event: Event) {
|
function onFileSelect(event: Event) {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
@@ -97,4 +181,28 @@ async function onRemove() {
|
|||||||
removing.value = false
|
removing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onCopy() {
|
||||||
|
if (!auth.user?.apiToken) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(auth.user.apiToken)
|
||||||
|
toast.success({ message: t('profile.apiToken.copied') })
|
||||||
|
} catch {
|
||||||
|
toast.error({ message: t('profile.apiToken.copyFailed') })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRegenerate() {
|
||||||
|
regenerating.value = true
|
||||||
|
try {
|
||||||
|
const newToken = await regenerate()
|
||||||
|
if (auth.user) {
|
||||||
|
auth.user.apiToken = newToken
|
||||||
|
}
|
||||||
|
showConfirm.value = false
|
||||||
|
toast.success({ message: t('profile.apiToken.regenerated') })
|
||||||
|
} finally {
|
||||||
|
regenerating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -84,7 +84,12 @@
|
|||||||
|
|
||||||
<!-- Expanded details -->
|
<!-- Expanded details -->
|
||||||
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
|
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-4 py-3">
|
||||||
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
<MalioInputRichText
|
||||||
|
v-if="ticket.description"
|
||||||
|
:model-value="ticket.description"
|
||||||
|
:editable="false"
|
||||||
|
/>
|
||||||
|
<p v-else class="text-sm italic text-neutral-400">—</p>
|
||||||
<div v-if="ticket.url" class="mt-2">
|
<div v-if="ticket.url" class="mt-2">
|
||||||
<a
|
<a
|
||||||
:href="ticket.url"
|
:href="ticket.url"
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
|
v-if="viewMode === 'list'"
|
||||||
v-model="selectedStatusId"
|
v-model="selectedStatusId"
|
||||||
:options="statusFilterOptions"
|
:options="statusFilterOptions"
|
||||||
label="Status"
|
label="Status"
|
||||||
@@ -258,6 +259,12 @@ const selectedStatusId = ref<number | null>(null)
|
|||||||
const selectedPriorityId = ref<number | null>(null)
|
const selectedPriorityId = ref<number | null>(null)
|
||||||
const selectedEffortId = ref<number | null>(null)
|
const selectedEffortId = ref<number | null>(null)
|
||||||
const viewMode = ref<'kanban' | 'list'>('kanban')
|
const viewMode = ref<'kanban' | 'list'>('kanban')
|
||||||
|
|
||||||
|
watch(viewMode, (mode) => {
|
||||||
|
if (mode === 'kanban') {
|
||||||
|
selectedStatusId.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
const selectedTaskIds = reactive(new Set<number>())
|
const selectedTaskIds = reactive(new Set<number>())
|
||||||
const dragOverStatusId = ref<number | null>(null)
|
const dragOverStatusId = ref<number | null>(null)
|
||||||
const dragCounter = ref(0)
|
const dragCounter = ref(0)
|
||||||
@@ -298,7 +305,10 @@ const filteredTasks = computed(() => {
|
|||||||
result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value))
|
result = result.filter(t => t.tags?.some(tag => tag.id === selectedTagId.value))
|
||||||
}
|
}
|
||||||
if (selectedAssigneeId.value) {
|
if (selectedAssigneeId.value) {
|
||||||
result = result.filter(t => t.assignee?.id === selectedAssigneeId.value)
|
result = result.filter(t =>
|
||||||
|
t.assignee?.id === selectedAssigneeId.value
|
||||||
|
|| t.collaborators?.some(c => c.id === selectedAssigneeId.value)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (selectedStatusId.value) {
|
if (selectedStatusId.value) {
|
||||||
result = result.filter(t => t.status?.id === selectedStatusId.value)
|
result = result.filter(t => t.status?.id === selectedStatusId.value)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
text-field="text-sm"
|
text-field="text-sm"
|
||||||
text-value="text-sm"
|
text-value="text-sm"
|
||||||
label="User"
|
label="User"
|
||||||
empty-option-label="User"
|
empty-option-label="Tous"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -217,16 +217,7 @@ function updatePageHeaderHeight() {
|
|||||||
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
|
pageHeaderHeight.value = pageHeaderEl.value?.offsetHeight ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredEntries = computed(() => {
|
const filteredEntries = computed(() => entries.value)
|
||||||
let result = entries.value
|
|
||||||
if (selectedProjectId.value) {
|
|
||||||
result = result.filter((e) => e.project?.id === selectedProjectId.value)
|
|
||||||
}
|
|
||||||
if (selectedTagId.value) {
|
|
||||||
result = result.filter((e) => e.tags.some((t) => t.id === selectedTagId.value))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
function getMonday(d: Date): Date {
|
function getMonday(d: Date): Date {
|
||||||
const date = new Date(d)
|
const date = new Date(d)
|
||||||
@@ -239,15 +230,35 @@ function getMonday(d: Date): Date {
|
|||||||
|
|
||||||
function navigatePrev() {
|
function navigatePrev() {
|
||||||
const d = new Date(startDate.value)
|
const d = new Date(startDate.value)
|
||||||
d.setDate(d.getDate() - (viewMode.value === 'day' ? 1 : 7))
|
if (viewMode.value === 'day') {
|
||||||
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
d.setDate(d.getDate() - 1)
|
||||||
|
startDate.value = d
|
||||||
|
} else if (viewMode.value === 'list') {
|
||||||
|
d.setMonth(d.getMonth() - 1)
|
||||||
|
d.setDate(1)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
startDate.value = d
|
||||||
|
} else {
|
||||||
|
d.setDate(d.getDate() - 7)
|
||||||
|
startDate.value = getMonday(d)
|
||||||
|
}
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateNext() {
|
function navigateNext() {
|
||||||
const d = new Date(startDate.value)
|
const d = new Date(startDate.value)
|
||||||
d.setDate(d.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
if (viewMode.value === 'day') {
|
||||||
startDate.value = viewMode.value === 'day' ? d : getMonday(d)
|
d.setDate(d.getDate() + 1)
|
||||||
|
startDate.value = d
|
||||||
|
} else if (viewMode.value === 'list') {
|
||||||
|
d.setMonth(d.getMonth() + 1)
|
||||||
|
d.setDate(1)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
startDate.value = d
|
||||||
|
} else {
|
||||||
|
d.setDate(d.getDate() + 7)
|
||||||
|
startDate.value = getMonday(d)
|
||||||
|
}
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,12 +370,20 @@ async function onExport(params: {
|
|||||||
|
|
||||||
async function loadEntries() {
|
async function loadEntries() {
|
||||||
const end = new Date(startDate.value)
|
const end = new Date(startDate.value)
|
||||||
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
|
if (viewMode.value === 'day') {
|
||||||
|
end.setDate(end.getDate() + 1)
|
||||||
|
} else if (viewMode.value === 'list') {
|
||||||
|
end.setMonth(end.getMonth() + 1)
|
||||||
|
} else {
|
||||||
|
end.setDate(end.getDate() + 7)
|
||||||
|
}
|
||||||
|
|
||||||
entries.value = await timeEntryService.getByDateRange({
|
entries.value = await timeEntryService.getByDateRange({
|
||||||
after: startDate.value.toISOString(),
|
after: startDate.value.toISOString(),
|
||||||
before: end.toISOString(),
|
before: end.toISOString(),
|
||||||
user: selectedUserId.value ?? undefined,
|
user: selectedUserId.value ?? undefined,
|
||||||
|
project: selectedProjectId.value ?? undefined,
|
||||||
|
tag: selectedTagId.value ?? undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,11 +419,20 @@ onMounted(async () => {
|
|||||||
|
|
||||||
watch(viewMode, () => {
|
watch(viewMode, () => {
|
||||||
selectedDateFilter.value = null
|
selectedDateFilter.value = null
|
||||||
startDate.value = viewMode.value === 'day' ? startDate.value : getMonday(startDate.value)
|
if (viewMode.value === 'day') {
|
||||||
|
// keep current date
|
||||||
|
} else if (viewMode.value === 'list') {
|
||||||
|
const d = new Date(startDate.value)
|
||||||
|
d.setDate(1)
|
||||||
|
d.setHours(0, 0, 0, 0)
|
||||||
|
startDate.value = d
|
||||||
|
} else {
|
||||||
|
startDate.value = getMonday(startDate.value)
|
||||||
|
}
|
||||||
loadEntries()
|
loadEntries()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedUserId, () => {
|
watch([selectedUserId, selectedProjectId, selectedTagId], () => {
|
||||||
loadEntries()
|
loadEntries()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
12
frontend/services/api-token.ts
Normal file
12
frontend/services/api-token.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export function useApiTokenService() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function regenerate(): Promise<string> {
|
||||||
|
const data = await api.post<{ apiToken: string }>('/me/regenerate-api-token', {}, {
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
return data.apiToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return { regenerate }
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export type Task = {
|
|||||||
effort: TaskEffort | null
|
effort: TaskEffort | null
|
||||||
priority: TaskPriority | null
|
priority: TaskPriority | null
|
||||||
assignee: UserData | null
|
assignee: UserData | null
|
||||||
|
collaborators: UserData[]
|
||||||
group: TaskGroup | null
|
group: TaskGroup | null
|
||||||
project: Project | null
|
project: Project | null
|
||||||
tags: TaskTag[]
|
tags: TaskTag[]
|
||||||
@@ -55,6 +56,7 @@ export type TaskWrite = {
|
|||||||
effort: string | null
|
effort: string | null
|
||||||
priority: string | null
|
priority: string | null
|
||||||
assignee: string | null
|
assignee: string | null
|
||||||
|
collaborators?: string[]
|
||||||
group: string | null
|
group: string | null
|
||||||
project: string
|
project: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type UserData = {
|
|||||||
client?: { id: number; name: string } | null
|
client?: { id: number; name: string } | null
|
||||||
allowedProjects?: Project[]
|
allowedProjects?: Project[]
|
||||||
avatarUrl?: string | null
|
avatarUrl?: string | null
|
||||||
|
apiToken?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserWrite = {
|
export type UserWrite = {
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export function useTimeEntryService() {
|
|||||||
after: string
|
after: string
|
||||||
before: string
|
before: string
|
||||||
user?: number
|
user?: number
|
||||||
types?: number[]
|
project?: number
|
||||||
|
tag?: number
|
||||||
}): Promise<TimeEntry[]> {
|
}): Promise<TimeEntry[]> {
|
||||||
const query: Record<string, unknown> = {
|
const query: Record<string, unknown> = {
|
||||||
'startedAt[after]': params.after,
|
'startedAt[after]': params.after,
|
||||||
@@ -18,6 +19,12 @@ export function useTimeEntryService() {
|
|||||||
if (params.user) {
|
if (params.user) {
|
||||||
query.user = `/api/users/${params.user}`
|
query.user = `/api/users/${params.user}`
|
||||||
}
|
}
|
||||||
|
if (params.project) {
|
||||||
|
query.project = `/api/projects/${params.project}`
|
||||||
|
}
|
||||||
|
if (params.tag) {
|
||||||
|
query['tags[]'] = `/api/task_tags/${params.tag}`
|
||||||
|
}
|
||||||
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query)
|
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query)
|
||||||
return extractHydraMembers(data)
|
return extractHydraMembers(data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type {Config} from 'tailwindcss'
|
import type {Config} from 'tailwindcss'
|
||||||
|
import typography from '@tailwindcss/typography'
|
||||||
|
|
||||||
export default <Partial<Config>>{
|
export default <Partial<Config>>{
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
|
plugins: [typography],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
|
|||||||
@@ -3,3 +3,17 @@ export function formatFileSize(bytes: number): string {
|
|||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
|
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function stripRichText(value: string | null | undefined): string {
|
||||||
|
if (!value) return ''
|
||||||
|
return value
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'|'/gi, '\'')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|||||||
22
infra/prod/.env.example
Normal file
22
infra/prod/.env.example
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Symfony
|
||||||
|
APP_ENV=prod
|
||||||
|
APP_DEBUG=0
|
||||||
|
APP_SECRET=change-me
|
||||||
|
|
||||||
|
# Database (use host.docker.internal to reach bare-metal PostgreSQL)
|
||||||
|
DATABASE_URL="postgresql://lesstime_user:password@host.docker.internal:5432/lesstime_prod?serverVersion=16&charset=utf8"
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||||
|
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||||
|
JWT_PASSPHRASE=change-me
|
||||||
|
JWT_COOKIE_SECURE=1
|
||||||
|
JWT_COOKIE_SAMESITE=lax
|
||||||
|
JWT_TOKEN_TTL=86400
|
||||||
|
JWT_COOKIE_TTL=86400
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
|
||||||
|
|
||||||
|
# App
|
||||||
|
DEFAULT_URI=https://project.malio-dev.fr
|
||||||
82
infra/prod/Dockerfile
Normal file
82
infra/prod/Dockerfile
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# --- Stage 1: Build backend ---
|
||||||
|
FROM php:8.4-cli AS backend-build
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
|
||||||
|
unzip curl git \
|
||||||
|
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY composer.json composer.lock symfony.lock ./
|
||||||
|
RUN APP_ENV=prod APP_DEBUG=0 composer install --no-dev --no-scripts --no-interaction
|
||||||
|
|
||||||
|
COPY bin bin/
|
||||||
|
COPY config config/
|
||||||
|
COPY migrations migrations/
|
||||||
|
COPY public public/
|
||||||
|
COPY src src/
|
||||||
|
|
||||||
|
RUN composer dump-autoload --optimize --no-dev
|
||||||
|
|
||||||
|
# --- Stage 2: Build frontend ---
|
||||||
|
FROM node:lts-alpine AS frontend-build
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY frontend/ ./
|
||||||
|
ENV CI=1 \
|
||||||
|
NUXT_TELEMETRY_DISABLED=1 \
|
||||||
|
NUXT_PUBLIC_API_BASE=/api \
|
||||||
|
NUXT_PUBLIC_APP_BASE=/
|
||||||
|
RUN npm run generate
|
||||||
|
|
||||||
|
# --- Stage 3: Production image ---
|
||||||
|
FROM php:8.4-fpm AS production
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
|
||||||
|
nginx supervisor \
|
||||||
|
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# PHP production config
|
||||||
|
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||||
|
|
||||||
|
# PHP-FPM: forward worker output to stderr for docker logs
|
||||||
|
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \
|
||||||
|
&& echo "decorate_workers_output = no" >> /usr/local/etc/php-fpm.d/www.conf
|
||||||
|
|
||||||
|
# Nginx: log to stdout/stderr
|
||||||
|
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
|
||||||
|
&& ln -sf /dev/stderr /var/log/nginx/error.log
|
||||||
|
|
||||||
|
# Remove default nginx site
|
||||||
|
RUN rm -f /etc/nginx/sites-enabled/default
|
||||||
|
|
||||||
|
# Configs
|
||||||
|
COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||||
|
COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/lesstime.conf
|
||||||
|
COPY infra/prod/maintenance.html /var/www/html/public/maintenance.html
|
||||||
|
|
||||||
|
# Backend from stage 1
|
||||||
|
COPY --from=backend-build /app /var/www/html
|
||||||
|
|
||||||
|
# Frontend from stage 2
|
||||||
|
COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.output/public
|
||||||
|
|
||||||
|
# Symfony needs a .env file to boot (variables are overridden by env_file in docker-compose)
|
||||||
|
RUN echo "APP_ENV=prod" > /var/www/html/.env
|
||||||
|
|
||||||
|
# Permissions
|
||||||
|
RUN mkdir -p /var/www/html/var /var/www/html/var/uploads /var/www/html/var/mcp-sessions \
|
||||||
|
&& chown -R www-data:www-data /var/www/html/var
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/app.conf"]
|
||||||
38
infra/prod/deploy.sh
Executable file
38
infra/prod/deploy.sh
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
TAG="${1:-latest}"
|
||||||
|
export LESSTIME_IMAGE_TAG="$TAG"
|
||||||
|
|
||||||
|
echo "==> Deploying lesstime:${TAG}..."
|
||||||
|
|
||||||
|
echo "==> Enabling maintenance mode..."
|
||||||
|
touch maintenance.on
|
||||||
|
|
||||||
|
echo "==> Pulling image..."
|
||||||
|
sudo docker compose pull
|
||||||
|
|
||||||
|
echo "==> Starting container..."
|
||||||
|
sudo docker compose up -d
|
||||||
|
|
||||||
|
echo "==> Waiting for container to be ready..."
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
echo "==> Extracting maintenance page..."
|
||||||
|
mkdir -p public
|
||||||
|
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||||
|
|
||||||
|
echo "==> Running migrations..."
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
|
||||||
|
echo "==> Clearing cache..."
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||||
|
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||||
|
|
||||||
|
echo "==> Disabling maintenance mode..."
|
||||||
|
rm -f maintenance.on
|
||||||
|
|
||||||
|
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||||
|
echo "==> Deployed v${VERSION}"
|
||||||
17
infra/prod/docker-compose.yml
Normal file
17
infra/prod/docker-compose.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: gitea.malio.fr/malio-dev/lesstime:${LESSTIME_IMAGE_TAG:-latest}
|
||||||
|
container_name: lesstime-app
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "8081:80"
|
||||||
|
volumes:
|
||||||
|
- ./config/jwt:/var/www/html/config/jwt:ro
|
||||||
|
- ./uploads:/var/www/html/var/uploads
|
||||||
|
- lesstime_logs:/var/www/html/var/log
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
lesstime_logs:
|
||||||
49
infra/prod/maintenance.html
Normal file
49
infra/prod/maintenance.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Maintenance en cours</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.10);
|
||||||
|
padding: 48px 40px;
|
||||||
|
max-width: 480px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="icon">🛠</div>
|
||||||
|
<h1>Maintenance en cours</h1>
|
||||||
|
<p>L'application est temporairement indisponible pour mise à jour. Elle sera de retour dans quelques instants.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
infra/prod/nginx-proxy.conf
Normal file
31
infra/prod/nginx-proxy.conf
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name project.malio-dev.fr;
|
||||||
|
|
||||||
|
root /var/www/lesstime/public;
|
||||||
|
|
||||||
|
# Maintenance mode
|
||||||
|
if (-f /var/www/lesstime/maintenance.on) {
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 503 @maintenance;
|
||||||
|
|
||||||
|
location @maintenance {
|
||||||
|
rewrite ^(.*)$ /maintenance.html break;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /maintenance.html {
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 55m;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
infra/prod/nginx.conf
Normal file
70
infra/prod/nginx.conf
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Maintenance mode
|
||||||
|
if (-f /var/www/html/maintenance.on) {
|
||||||
|
return 503;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 503 @maintenance;
|
||||||
|
|
||||||
|
location @maintenance {
|
||||||
|
root /var/www/html/public;
|
||||||
|
rewrite ^(.*)$ /maintenance.html break;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /maintenance.html {
|
||||||
|
root /var/www/html/public;
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
root /var/www/html/frontend/.output/public;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
client_max_body_size 55m;
|
||||||
|
|
||||||
|
access_log /dev/stdout;
|
||||||
|
error_log /dev/stderr;
|
||||||
|
|
||||||
|
location ^~ /api/ {
|
||||||
|
root /var/www/html/public;
|
||||||
|
try_files $uri /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /bundles/ {
|
||||||
|
root /var/www/html/public;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /api/login_check {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
|
||||||
|
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
|
||||||
|
fastcgi_param SCRIPT_NAME /index.php;
|
||||||
|
fastcgi_param PATH_INFO /login_check;
|
||||||
|
fastcgi_param REQUEST_URI /login_check;
|
||||||
|
fastcgi_pass 127.0.0.1:9000;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /_mcp {
|
||||||
|
root /var/www/html/public;
|
||||||
|
try_files $uri /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/index\.php(/|$) {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
|
||||||
|
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
|
||||||
|
fastcgi_pass 127.0.0.1:9000;
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
infra/prod/supervisord.conf
Normal file
28
infra/prod/supervisord.conf
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
logfile=/dev/null
|
||||||
|
logfile_maxbytes=0
|
||||||
|
pidfile=/var/run/supervisord.pid
|
||||||
|
|
||||||
|
[program:php-fpm]
|
||||||
|
command=php-fpm -F
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stopasgroup=true
|
||||||
|
stopsignal=QUIT
|
||||||
|
|
||||||
|
[program:nginx]
|
||||||
|
command=nginx -g "daemon off;"
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stopasgroup=true
|
||||||
|
stopsignal=QUIT
|
||||||
6
makefile
6
makefile
@@ -1,6 +1,6 @@
|
|||||||
# Permet d'utiliser un .env.docker.local pour override
|
# Permet d'utiliser un .env.docker.local pour override
|
||||||
ENV_DEFAULT = docker/.env.docker
|
ENV_DEFAULT = infra/dev/.env.docker
|
||||||
ENV_LOCAL = docker/.env.docker.local
|
ENV_LOCAL = infra/dev/.env.docker.local
|
||||||
ENV_FILE := $(if $(wildcard $(ENV_LOCAL)),$(ENV_LOCAL),$(ENV_DEFAULT))
|
ENV_FILE := $(if $(wildcard $(ENV_LOCAL)),$(ENV_LOCAL),$(ENV_DEFAULT))
|
||||||
|
|
||||||
# Permet d'avoir les variables du fichier .env.docker.local
|
# Permet d'avoir les variables du fichier .env.docker.local
|
||||||
@@ -23,13 +23,11 @@ FILES =
|
|||||||
#========================================================================================
|
#========================================================================================
|
||||||
|
|
||||||
env-init:
|
env-init:
|
||||||
@mkdir -p docker
|
|
||||||
@cp --update=none $(ENV_DEFAULT) $(ENV_LOCAL)
|
@cp --update=none $(ENV_DEFAULT) $(ENV_LOCAL)
|
||||||
|
|
||||||
# Lance le container
|
# Lance le container
|
||||||
start: env-init
|
start: env-init
|
||||||
@echo "**** START CONTAINERS ****"
|
@echo "**** START CONTAINERS ****"
|
||||||
@cp --update=none docker/.env.docker docker/.env.docker.local
|
|
||||||
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
||||||
|
|
||||||
# Éteint le container
|
# Éteint le container
|
||||||
|
|||||||
37
migrations/Version20260409075411.php
Normal file
37
migrations/Version20260409075411.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?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 Version20260409075411 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_collaborator (task_id INT NOT NULL, user_id INT NOT NULL, PRIMARY KEY (task_id, user_id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_A8FC6C518DB60186 ON task_collaborator (task_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_A8FC6C51A76ED395 ON task_collaborator (user_id)');
|
||||||
|
$this->addSql('ALTER TABLE task_collaborator ADD CONSTRAINT FK_A8FC6C518DB60186 FOREIGN KEY (task_id) REFERENCES task (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||||
|
$this->addSql('ALTER TABLE task_collaborator ADD CONSTRAINT FK_A8FC6C51A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE task_collaborator DROP CONSTRAINT FK_A8FC6C518DB60186');
|
||||||
|
$this->addSql('ALTER TABLE task_collaborator DROP CONSTRAINT FK_A8FC6C51A76ED395');
|
||||||
|
$this->addSql('DROP TABLE task_collaborator');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Usage: ./script/deploy-release.sh v0.1.0
|
|
||||||
# Requires: curl, tar, (optional) rsync
|
|
||||||
#
|
|
||||||
# Auth token: set RELEASE_TOKEN env var or create /etc/lesstime-release-token
|
|
||||||
umask 002
|
|
||||||
|
|
||||||
TAG="${1:-}"
|
|
||||||
if [ -z "$TAG" ]; then
|
|
||||||
echo "Usage: $0 v0.1.0" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
REPO_OWNER="MALIO-DEV"
|
|
||||||
REPO_NAME="Lesstime"
|
|
||||||
GITEA_API="https://gitea.malio.fr/api/v1"
|
|
||||||
DEPLOY_DIR="/var/www/lesstime"
|
|
||||||
|
|
||||||
if [ -f /etc/lesstime-release-token ] && [ -z "${RELEASE_TOKEN:-}" ]; then
|
|
||||||
RELEASE_TOKEN="$(cat /etc/lesstime-release-token)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
tmp_dir="$(mktemp -d)"
|
|
||||||
cleanup() {
|
|
||||||
rm -rf "$tmp_dir"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
release_json="$tmp_dir/release.json"
|
|
||||||
curl_opts=(-sS)
|
|
||||||
if [ -n "${RELEASE_TOKEN:-}" ]; then
|
|
||||||
curl_opts+=(-H "Authorization: token ${RELEASE_TOKEN}")
|
|
||||||
fi
|
|
||||||
curl "${curl_opts[@]}" \
|
|
||||||
"${GITEA_API}/repos/${REPO_OWNER}/${REPO_NAME}/releases/tags/${TAG}" \
|
|
||||||
-o "$release_json"
|
|
||||||
|
|
||||||
asset_url="$(python3 - "$release_json" <<'PY'
|
|
||||||
import json, sys
|
|
||||||
data = json.load(open(sys.argv[1], 'r'))
|
|
||||||
assets = data.get("assets", [])
|
|
||||||
for a in assets:
|
|
||||||
name = a.get("name", "")
|
|
||||||
if name.startswith("lesstime-") and name.endswith(".tar.gz"):
|
|
||||||
print(a.get("browser_download_url", ""))
|
|
||||||
break
|
|
||||||
PY
|
|
||||||
)"
|
|
||||||
|
|
||||||
if [ -z "$asset_url" ]; then
|
|
||||||
echo "Release asset not found for tag ${TAG}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
archive="$tmp_dir/artefact.tar.gz"
|
|
||||||
curl "${curl_opts[@]}" -L "$asset_url" -o "$archive"
|
|
||||||
|
|
||||||
tar -xzf "$archive" -C "$tmp_dir"
|
|
||||||
|
|
||||||
if command -v rsync >/dev/null 2>&1; then
|
|
||||||
rsync -a --delete --no-perms --no-owner --no-group \
|
|
||||||
--exclude ".env" \
|
|
||||||
--exclude ".env.local" \
|
|
||||||
--exclude "config/jwt" \
|
|
||||||
--exclude "var" \
|
|
||||||
"$tmp_dir"/ "$DEPLOY_DIR"/
|
|
||||||
else
|
|
||||||
cp -a "$tmp_dir"/. "$DEPLOY_DIR"/
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure Nginx can traverse the deploy path.
|
|
||||||
chmod o+rx "$(dirname "$DEPLOY_DIR")" "$DEPLOY_DIR" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Create frontend/dist symlink if needed (nginx serves from frontend/dist)
|
|
||||||
if [ -d "${DEPLOY_DIR}/frontend/.output/public" ] && [ ! -L "${DEPLOY_DIR}/frontend/dist" ]; then
|
|
||||||
ln -sfn "${DEPLOY_DIR}/frontend/.output/public" "${DEPLOY_DIR}/frontend/dist"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Release ${TAG} deployed to ${DEPLOY_DIR}"
|
|
||||||
|
|
||||||
# Ensure var/log exists and is writable by PHP (www-data)
|
|
||||||
mkdir -p "${DEPLOY_DIR}/var/log"
|
|
||||||
chown www-data:www-data "${DEPLOY_DIR}/var/log"
|
|
||||||
chmod 775 "${DEPLOY_DIR}/var/log"
|
|
||||||
|
|
||||||
if [ -f "${DEPLOY_DIR}/.env.local" ]; then
|
|
||||||
echo "Clearing cache..."
|
|
||||||
php "${DEPLOY_DIR}/bin/console" cache:clear --env=prod --no-debug
|
|
||||||
|
|
||||||
echo "Running migrations (if any)..."
|
|
||||||
php "${DEPLOY_DIR}/bin/console" doctrine:migrations:migrate --no-interaction --env=prod
|
|
||||||
else
|
|
||||||
echo "Skip post-deploy: ${DEPLOY_DIR}/.env.local not found" >&2
|
|
||||||
fi
|
|
||||||
36
src/Controller/RegenerateApiTokenController.php
Normal file
36
src/Controller/RegenerateApiTokenController.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
use function bin2hex;
|
||||||
|
use function random_bytes;
|
||||||
|
|
||||||
|
class RegenerateApiTokenController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/api/me/regenerate-api-token', name: 'me_regenerate_api_token', methods: ['POST'], priority: 1)]
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
public function __invoke(): JsonResponse
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$user->setApiToken($token);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return new JsonResponse(['apiToken' => $token]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
denormalizationContext: ['groups' => ['task:write']],
|
denormalizationContext: ['groups' => ['task:write']],
|
||||||
order: ['id' => 'DESC'],
|
order: ['id' => 'DESC'],
|
||||||
)]
|
)]
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
|
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'group' => 'exact', 'assignee' => 'exact', 'collaborators' => 'exact', 'priority' => 'exact', 'effort' => 'exact', 'tags' => 'exact', 'status' => 'exact'])]
|
||||||
#[ApiFilter(DateFilter::class, properties: ['scheduledStart', 'scheduledEnd', 'deadline'])]
|
#[ApiFilter(DateFilter::class, properties: ['scheduledStart', 'scheduledEnd', 'deadline'])]
|
||||||
#[ApiFilter(BooleanFilter::class, properties: ['archived', 'syncToCalendar'])]
|
#[ApiFilter(BooleanFilter::class, properties: ['archived', 'syncToCalendar'])]
|
||||||
#[ApiFilter(OrderFilter::class, properties: ['scheduledStart', 'deadline'])]
|
#[ApiFilter(OrderFilter::class, properties: ['scheduledStart', 'deadline'])]
|
||||||
@@ -85,6 +85,16 @@ class Task
|
|||||||
#[Groups(['task:read', 'task:write'])]
|
#[Groups(['task:read', 'task:write'])]
|
||||||
private ?User $assignee = null;
|
private ?User $assignee = null;
|
||||||
|
|
||||||
|
/** @var Collection<int, User> */
|
||||||
|
#[ORM\ManyToMany(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinTable(
|
||||||
|
name: 'task_collaborator',
|
||||||
|
joinColumns: [new ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
|
||||||
|
inverseJoinColumns: [new ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
|
||||||
|
)]
|
||||||
|
#[Groups(['task:read', 'task:write'])]
|
||||||
|
private Collection $collaborators;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: TaskGroup::class)]
|
#[ORM\ManyToOne(targetEntity: TaskGroup::class)]
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
#[Groups(['task:read', 'task:write'])]
|
#[Groups(['task:read', 'task:write'])]
|
||||||
@@ -152,8 +162,9 @@ class Task
|
|||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->tags = new ArrayCollection();
|
$this->tags = new ArrayCollection();
|
||||||
$this->documents = new ArrayCollection();
|
$this->documents = new ArrayCollection();
|
||||||
|
$this->collaborators = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
@@ -245,6 +256,28 @@ class Task
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, User> */
|
||||||
|
public function getCollaborators(): Collection
|
||||||
|
{
|
||||||
|
return $this->collaborators;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addCollaborator(User $user): static
|
||||||
|
{
|
||||||
|
if (!$this->collaborators->contains($user)) {
|
||||||
|
$this->collaborators->add($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeCollaborator(User $user): static
|
||||||
|
{
|
||||||
|
$this->collaborators->removeElement($user);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getGroup(): ?TaskGroup
|
public function getGroup(): ?TaskGroup
|
||||||
{
|
{
|
||||||
return $this->group;
|
return $this->group;
|
||||||
@@ -434,4 +467,15 @@ class Task
|
|||||||
;
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Assert\Callback]
|
||||||
|
public function validateCollaborators(ExecutionContextInterface $context): void
|
||||||
|
{
|
||||||
|
if (null !== $this->assignee && $this->collaborators->contains($this->assignee)) {
|
||||||
|
$context->buildViolation('The assignee cannot also be a collaborator.')
|
||||||
|
->atPath('collaborators')
|
||||||
|
->addViolation()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
private ?DateTimeImmutable $createdAt = null;
|
private ?DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 64, unique: true, nullable: true)]
|
#[ORM\Column(length: 64, unique: true, nullable: true)]
|
||||||
|
#[Groups(['me:read'])]
|
||||||
private ?string $apiToken = null;
|
private ?string $apiToken = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 255, nullable: true)]
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
|||||||
@@ -134,6 +134,19 @@ final class Serializer
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, User> $users
|
||||||
|
*
|
||||||
|
* @return list<array{id: ?int, username: ?string}>
|
||||||
|
*/
|
||||||
|
public static function users(Collection $users): array
|
||||||
|
{
|
||||||
|
return $users->map(fn (User $u) => [
|
||||||
|
'id' => $u->getId(),
|
||||||
|
'username' => $u->getUsername(),
|
||||||
|
])->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return null|array{id: ?int, title: ?string, color: ?string}
|
* @return null|array{id: ?int, title: ?string, color: ?string}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class CreateTaskTool
|
|||||||
?int $assigneeId = null,
|
?int $assigneeId = null,
|
||||||
?int $groupId = null,
|
?int $groupId = null,
|
||||||
?array $tagIds = null,
|
?array $tagIds = null,
|
||||||
|
?array $collaboratorIds = null,
|
||||||
?string $scheduledStart = null,
|
?string $scheduledStart = null,
|
||||||
?string $scheduledEnd = null,
|
?string $scheduledEnd = null,
|
||||||
?string $deadline = null,
|
?string $deadline = null,
|
||||||
@@ -116,6 +117,18 @@ class CreateTaskTool
|
|||||||
$task->addTag($tag);
|
$task->addTag($tag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (null !== $collaboratorIds) {
|
||||||
|
foreach ($collaboratorIds as $collaboratorId) {
|
||||||
|
$collaborator = $this->userRepository->find($collaboratorId);
|
||||||
|
if (null === $collaborator) {
|
||||||
|
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $collaboratorId));
|
||||||
|
}
|
||||||
|
if (null !== $assigneeId && $collaboratorId === $assigneeId) {
|
||||||
|
throw new InvalidArgumentException('A collaborator cannot be the assignee.');
|
||||||
|
}
|
||||||
|
$task->addCollaborator($collaborator);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (null !== $scheduledStart) {
|
if (null !== $scheduledStart) {
|
||||||
$task->setScheduledStart(new DateTimeImmutable($scheduledStart));
|
$task->setScheduledStart(new DateTimeImmutable($scheduledStart));
|
||||||
}
|
}
|
||||||
@@ -147,6 +160,7 @@ class CreateTaskTool
|
|||||||
'priority' => Serializer::priority($task->getPriority()),
|
'priority' => Serializer::priority($task->getPriority()),
|
||||||
'effort' => Serializer::effort($task->getEffort()),
|
'effort' => Serializer::effort($task->getEffort()),
|
||||||
'assignee' => Serializer::user($task->getAssignee()),
|
'assignee' => Serializer::user($task->getAssignee()),
|
||||||
|
'collaborators' => Serializer::users($task->getCollaborators()),
|
||||||
'group' => Serializer::groupRef($task->getGroup()),
|
'group' => Serializer::groupRef($task->getGroup()),
|
||||||
'project' => Serializer::projectRef($project),
|
'project' => Serializer::projectRef($project),
|
||||||
'tags' => Serializer::tags($task->getTags()),
|
'tags' => Serializer::tags($task->getTags()),
|
||||||
|
|||||||
@@ -34,19 +34,20 @@ class GetTaskTool
|
|||||||
}
|
}
|
||||||
|
|
||||||
return json_encode([
|
return json_encode([
|
||||||
'id' => $task->getId(),
|
'id' => $task->getId(),
|
||||||
'number' => $task->getNumber(),
|
'number' => $task->getNumber(),
|
||||||
'title' => $task->getTitle(),
|
'title' => $task->getTitle(),
|
||||||
'description' => $task->getDescription(),
|
'description' => $task->getDescription(),
|
||||||
'status' => Serializer::statusFull($task->getStatus()),
|
'status' => Serializer::statusFull($task->getStatus()),
|
||||||
'priority' => Serializer::priority($task->getPriority()),
|
'priority' => Serializer::priority($task->getPriority()),
|
||||||
'effort' => Serializer::effort($task->getEffort()),
|
'effort' => Serializer::effort($task->getEffort()),
|
||||||
'assignee' => Serializer::user($task->getAssignee()),
|
'assignee' => Serializer::user($task->getAssignee()),
|
||||||
'group' => Serializer::group($task->getGroup()),
|
'collaborators' => Serializer::users($task->getCollaborators()),
|
||||||
'project' => Serializer::projectRef($task->getProject()),
|
'group' => Serializer::group($task->getGroup()),
|
||||||
'tags' => Serializer::tagsWithColor($task->getTags()),
|
'project' => Serializer::projectRef($task->getProject()),
|
||||||
'documents' => Serializer::documents($task->getDocuments()),
|
'tags' => Serializer::tagsWithColor($task->getTags()),
|
||||||
'archived' => $task->isArchived(),
|
'documents' => Serializer::documents($task->getDocuments()),
|
||||||
|
'archived' => $task->isArchived(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use Mcp\Capability\Attribute\McpTool;
|
|||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||||
|
|
||||||
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')]
|
#[McpTool(name: 'list-tasks', description: 'List tasks with optional filters by project, status, assignee, collaborator, priority, group, tags, and archive state. Returns max 100 results by default, use filters to narrow down.')]
|
||||||
class ListTasksTool
|
class ListTasksTool
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -22,6 +22,7 @@ class ListTasksTool
|
|||||||
?int $projectId = null,
|
?int $projectId = null,
|
||||||
?int $statusId = null,
|
?int $statusId = null,
|
||||||
?int $assigneeId = null,
|
?int $assigneeId = null,
|
||||||
|
?int $collaboratorId = null,
|
||||||
?int $priorityId = null,
|
?int $priorityId = null,
|
||||||
?int $groupId = null,
|
?int $groupId = null,
|
||||||
?array $tagIds = null,
|
?array $tagIds = null,
|
||||||
@@ -38,6 +39,7 @@ class ListTasksTool
|
|||||||
->leftJoin('t.status', 's')->addSelect('s')
|
->leftJoin('t.status', 's')->addSelect('s')
|
||||||
->leftJoin('t.priority', 'p')->addSelect('p')
|
->leftJoin('t.priority', 'p')->addSelect('p')
|
||||||
->leftJoin('t.assignee', 'a')->addSelect('a')
|
->leftJoin('t.assignee', 'a')->addSelect('a')
|
||||||
|
->leftJoin('t.collaborators', 'collab')->addSelect('collab')
|
||||||
->leftJoin('t.project', 'pr')->addSelect('pr')
|
->leftJoin('t.project', 'pr')->addSelect('pr')
|
||||||
->leftJoin('t.effort', 'e')->addSelect('e')
|
->leftJoin('t.effort', 'e')->addSelect('e')
|
||||||
->leftJoin('t.group', 'g')->addSelect('g')
|
->leftJoin('t.group', 'g')->addSelect('g')
|
||||||
@@ -57,6 +59,9 @@ class ListTasksTool
|
|||||||
if (null !== $assigneeId) {
|
if (null !== $assigneeId) {
|
||||||
$qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId);
|
$qb->andWhere('a.id = :assigneeId')->setParameter('assigneeId', $assigneeId);
|
||||||
}
|
}
|
||||||
|
if (null !== $collaboratorId) {
|
||||||
|
$qb->andWhere('collab.id = :collaboratorId')->setParameter('collaboratorId', $collaboratorId);
|
||||||
|
}
|
||||||
if (null !== $priorityId) {
|
if (null !== $priorityId) {
|
||||||
$qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId);
|
$qb->andWhere('p.id = :priorityId')->setParameter('priorityId', $priorityId);
|
||||||
}
|
}
|
||||||
@@ -75,17 +80,18 @@ class ListTasksTool
|
|||||||
}
|
}
|
||||||
|
|
||||||
return json_encode(array_map(fn ($task) => [
|
return json_encode(array_map(fn ($task) => [
|
||||||
'id' => $task->getId(),
|
'id' => $task->getId(),
|
||||||
'number' => $task->getNumber(),
|
'number' => $task->getNumber(),
|
||||||
'title' => $task->getTitle(),
|
'title' => $task->getTitle(),
|
||||||
'status' => Serializer::status($task->getStatus()),
|
'status' => Serializer::status($task->getStatus()),
|
||||||
'priority' => Serializer::priority($task->getPriority()),
|
'priority' => Serializer::priority($task->getPriority()),
|
||||||
'assignee' => Serializer::user($task->getAssignee()),
|
'assignee' => Serializer::user($task->getAssignee()),
|
||||||
'effort' => Serializer::effort($task->getEffort()),
|
'collaborators' => Serializer::users($task->getCollaborators()),
|
||||||
'group' => Serializer::groupRef($task->getGroup()),
|
'effort' => Serializer::effort($task->getEffort()),
|
||||||
'project' => Serializer::projectRef($task->getProject()),
|
'group' => Serializer::groupRef($task->getGroup()),
|
||||||
'tags' => Serializer::tags($task->getTags()),
|
'project' => Serializer::projectRef($task->getProject()),
|
||||||
'archived' => $task->isArchived(),
|
'tags' => Serializer::tags($task->getTags()),
|
||||||
|
'archived' => $task->isArchived(),
|
||||||
], array_values($tasks)));
|
], array_values($tasks)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class UpdateTaskTool
|
|||||||
?int $assigneeId = null,
|
?int $assigneeId = null,
|
||||||
?int $groupId = null,
|
?int $groupId = null,
|
||||||
?array $tagIds = null,
|
?array $tagIds = null,
|
||||||
|
?array $collaboratorIds = null,
|
||||||
?bool $archived = null,
|
?bool $archived = null,
|
||||||
?string $scheduledStart = null,
|
?string $scheduledStart = null,
|
||||||
?string $scheduledEnd = null,
|
?string $scheduledEnd = null,
|
||||||
@@ -118,6 +119,22 @@ class UpdateTaskTool
|
|||||||
$task->addTag($tag);
|
$task->addTag($tag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (null !== $collaboratorIds) {
|
||||||
|
foreach ($task->getCollaborators()->toArray() as $existing) {
|
||||||
|
$task->removeCollaborator($existing);
|
||||||
|
}
|
||||||
|
$assignee = $task->getAssignee();
|
||||||
|
foreach ($collaboratorIds as $collaboratorId) {
|
||||||
|
$collaborator = $this->userRepository->find($collaboratorId);
|
||||||
|
if (null === $collaborator) {
|
||||||
|
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $collaboratorId));
|
||||||
|
}
|
||||||
|
if (null !== $assignee && $collaborator->getId() === $assignee->getId()) {
|
||||||
|
throw new InvalidArgumentException('A collaborator cannot be the assignee.');
|
||||||
|
}
|
||||||
|
$task->addCollaborator($collaborator);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (null !== $archived) {
|
if (null !== $archived) {
|
||||||
$task->setArchived($archived);
|
$task->setArchived($archived);
|
||||||
}
|
}
|
||||||
@@ -147,6 +164,7 @@ class UpdateTaskTool
|
|||||||
'priority' => Serializer::priority($task->getPriority()),
|
'priority' => Serializer::priority($task->getPriority()),
|
||||||
'effort' => Serializer::effort($task->getEffort()),
|
'effort' => Serializer::effort($task->getEffort()),
|
||||||
'assignee' => Serializer::user($task->getAssignee()),
|
'assignee' => Serializer::user($task->getAssignee()),
|
||||||
|
'collaborators' => Serializer::users($task->getCollaborators()),
|
||||||
'group' => Serializer::groupRef($task->getGroup()),
|
'group' => Serializer::groupRef($task->getGroup()),
|
||||||
'project' => Serializer::projectRef($task->getProject()),
|
'project' => Serializer::projectRef($task->getProject()),
|
||||||
'tags' => Serializer::tags($task->getTags()),
|
'tags' => Serializer::tags($task->getTags()),
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ use Sabre\VObject\Component\VCalendar;
|
|||||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
|
use const ENT_HTML5;
|
||||||
|
use const ENT_QUOTES;
|
||||||
|
|
||||||
final class CalDavService
|
final class CalDavService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -199,7 +202,7 @@ final class CalDavService
|
|||||||
$project = $task->getProject();
|
$project = $task->getProject();
|
||||||
$projectCode = null !== $project ? $project->getCode() : '';
|
$projectCode = null !== $project ? $project->getCode() : '';
|
||||||
$summary = sprintf('[%s-%s] %s', $projectCode, $task->getNumber(), $task->getTitle());
|
$summary = sprintf('[%s-%s] %s', $projectCode, $task->getNumber(), $task->getTitle());
|
||||||
$description = ($task->getDescription() ?? '')."\n\nLesstime task";
|
$description = $this->descriptionToPlainText($task->getDescription())."\n\nLesstime task";
|
||||||
|
|
||||||
$vcalendar = new VCalendar();
|
$vcalendar = new VCalendar();
|
||||||
$vcalendar->add('VEVENT', [
|
$vcalendar->add('VEVENT', [
|
||||||
@@ -225,7 +228,7 @@ final class CalDavService
|
|||||||
$project = $task->getProject();
|
$project = $task->getProject();
|
||||||
$projectCode = null !== $project ? $project->getCode() : '';
|
$projectCode = null !== $project ? $project->getCode() : '';
|
||||||
$summary = sprintf('[%s-%s] %s (deadline)', $projectCode, $task->getNumber(), $task->getTitle());
|
$summary = sprintf('[%s-%s] %s (deadline)', $projectCode, $task->getNumber(), $task->getTitle());
|
||||||
$description = ($task->getDescription() ?? '')."\n\nLesstime task";
|
$description = $this->descriptionToPlainText($task->getDescription())."\n\nLesstime task";
|
||||||
|
|
||||||
$vcalendar = new VCalendar();
|
$vcalendar = new VCalendar();
|
||||||
$vcalendar->add('VTODO', [
|
$vcalendar->add('VTODO', [
|
||||||
@@ -337,6 +340,18 @@ final class CalDavService
|
|||||||
return sprintf('%s@lesstime', bin2hex(random_bytes(16)));
|
return sprintf('%s@lesstime', bin2hex(random_bytes(16)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function descriptionToPlainText(?string $value): string
|
||||||
|
{
|
||||||
|
if (null === $value || '' === $value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$stripped = strip_tags($value);
|
||||||
|
$decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
|
||||||
|
return trim((string) preg_replace('/[ \t]+/', ' ', $decoded));
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string, string> */
|
/** @return array<string, string> */
|
||||||
private function getDayMap(): array
|
private function getDayMap(): array
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user