Compare commits
13 Commits
202b516dc3
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f99098291 | ||
|
|
1c6f473dff | ||
|
|
c95fff530c | ||
|
|
fb0e6c1ea4 | ||
|
|
6d3ecc1322 | ||
|
|
f5986090c0 | ||
|
|
d6399c20e1 | ||
|
|
a972d243f5 | ||
|
|
56bf88f293 | ||
| 9d80e017c2 | |||
| 4e91507158 | |||
| 318f14ea88 | |||
|
|
4216f1b5a1 |
@@ -45,6 +45,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
mkdir -p release
|
||||
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
|
||||
.env \
|
||||
bin \
|
||||
config \
|
||||
migrations \
|
||||
|
||||
@@ -16,7 +16,7 @@ src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatu
|
||||
src/ApiResource/ # Ressources API Platform (si découplées des entités)
|
||||
src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor)
|
||||
src/Service/ # Services métier (NotificationService)
|
||||
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController)
|
||||
src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController)
|
||||
src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/)
|
||||
src/Security/ # Authenticators custom (ApiTokenAuthenticator pour MCP HTTP)
|
||||
src/Command/ # Commandes console (GenerateApiTokenCommand)
|
||||
@@ -28,10 +28,10 @@ migrations/ # Migrations Doctrine
|
||||
docs/plans/ # Plans d'implémentation
|
||||
docs/superpowers/ # Plans et specs superpowers
|
||||
frontend/ # App Nuxt 4
|
||||
frontend/pages/ # Pages (index, login, my-tasks, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket)
|
||||
frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket)
|
||||
frontend/layouts/ # Layouts (default, portal)
|
||||
frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/)
|
||||
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers)
|
||||
frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService)
|
||||
frontend/stores/ # Stores Pinia (auth, ui, timer)
|
||||
frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents)
|
||||
frontend/services/dto/ # Types TypeScript
|
||||
@@ -80,6 +80,8 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash`
|
||||
- PostgreSQL : `LIKE` sur colonne JSON ne marche pas → utiliser `roles::text LIKE` via native SQL
|
||||
- Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}`
|
||||
- Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux propriétés de l'entité cible
|
||||
- Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider côté serveur — nécessite `symfony/mime`
|
||||
- Auth endpoints mixtes (ROLE_USER + ROLE_CLIENT) : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique
|
||||
|
||||
### Frontend
|
||||
|
||||
|
||||
190
README.md
190
README.md
@@ -1,10 +1,173 @@
|
||||
# Lesstime
|
||||
|
||||
Application de gestion de projet. Symfony 8 + API Platform 4 + Nuxt 4.
|
||||
Application de gestion de projet avec suivi du temps et portail client.
|
||||
|
||||
## MCP Server
|
||||
## Stack
|
||||
|
||||
Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistants IA (Claude Code, ChatGPT, Codex) d'interagir avec les projets, tâches et le suivi du temps.
|
||||
| Couche | Technologies |
|
||||
|--------|-------------|
|
||||
| **Backend** | PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM |
|
||||
| **Frontend** | Nuxt 4 (SPA), Vue 3, Pinia, Tailwind CSS |
|
||||
| **Base de données** | PostgreSQL 16 |
|
||||
| **Auth** | JWT HTTP-only cookie (lexik/jwt-authentication-bundle) |
|
||||
| **Infrastructure** | Docker (PHP-FPM, Nginx, PostgreSQL) |
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- Gestion de projets et tâches (kanban, groupes, priorités, tags, efforts)
|
||||
- Suivi du temps (timer, calendrier, vue liste)
|
||||
- Portail client avec tickets (bug, amélioration, autre)
|
||||
- Gestion de documents (upload, prévisualisation, téléchargement)
|
||||
- Profil utilisateur avec avatar (crop circulaire)
|
||||
- Notifications temps réel
|
||||
- Intégration Gitea (issues, repos)
|
||||
- Serveur MCP pour assistants IA
|
||||
- Multi-langue (i18n)
|
||||
|
||||
## Prérequis
|
||||
|
||||
- Docker & Docker Compose
|
||||
- Git
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# 1. Cloner le repo
|
||||
git clone <url> && cd lesstime
|
||||
|
||||
# 2. Démarrer les containers
|
||||
make start
|
||||
|
||||
# 3. Installation complète (composer, migrations, fixtures, build Nuxt)
|
||||
make install
|
||||
```
|
||||
|
||||
L'application est accessible sur **http://localhost:8082**.
|
||||
|
||||
### Comptes de test (fixtures)
|
||||
|
||||
| Utilisateur | Mot de passe | Rôle | Détails |
|
||||
|-------------|-------------|------|---------|
|
||||
| `admin` | `admin` | ROLE_ADMIN | Administrateur |
|
||||
| `alice` | `alice` | ROLE_USER | Utilisateur interne |
|
||||
| `bob` | `bob` | ROLE_USER | Utilisateur interne |
|
||||
| `charlie` | `charlie` | ROLE_USER | Utilisateur interne |
|
||||
| `client-liot` | `client` | ROLE_CLIENT | Client LIOT (projet SIRH) |
|
||||
| `client-acme` | `client` | ROLE_CLIENT | Client ACME (projet CRM) |
|
||||
|
||||
## Commandes
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
make start # Démarrer les containers
|
||||
make stop # Arrêter les containers
|
||||
make restart # Redémarrer les containers
|
||||
make shell # Shell dans le container PHP
|
||||
make shell-root # Shell root dans le container PHP
|
||||
```
|
||||
|
||||
### Développement
|
||||
|
||||
```bash
|
||||
make dev-nuxt # Dev server Nuxt (hot reload, port 3002)
|
||||
make cache-clear # Vider le cache Symfony
|
||||
make logs-dev # Tail logs Symfony
|
||||
```
|
||||
|
||||
### Base de données
|
||||
|
||||
```bash
|
||||
make migration-migrate # Lancer les migrations
|
||||
make fixtures # Charger les fixtures
|
||||
make db-reset # Reset BDD + migrations + fixtures (⚠️ supprime les données)
|
||||
```
|
||||
|
||||
### Tests & Qualité
|
||||
|
||||
```bash
|
||||
make test # PHPUnit
|
||||
make php-cs-fixer-allow-risky # Fix code style PHP (Symfony + PSR-12)
|
||||
```
|
||||
|
||||
### Installation complète
|
||||
|
||||
```bash
|
||||
make install # Composer + migrations + fixtures + build Nuxt
|
||||
make reset # Tout supprimer et réinstaller (⚠️ supprime la BDD)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── Entity/ # Entités Doctrine
|
||||
├── ApiResource/ # Ressources API Platform (découplées)
|
||||
├── State/ # Providers et Processors API Platform
|
||||
├── Controller/ # Controllers custom Symfony
|
||||
├── Service/ # Services métier
|
||||
├── EventListener/ # Listeners Doctrine
|
||||
├── Exception/ # Exceptions custom
|
||||
├── Security/ # Authenticators custom
|
||||
├── Repository/ # Repositories Doctrine
|
||||
├── Command/ # Commandes console
|
||||
├── DataFixtures/ # Fixtures
|
||||
└── Mcp/Tool/ # MCP tools par domaine
|
||||
├── Project/
|
||||
├── Task/
|
||||
├── TaskMeta/
|
||||
├── TimeEntry/
|
||||
└── Reference/
|
||||
|
||||
frontend/
|
||||
├── pages/ # Pages Nuxt (routing auto)
|
||||
│ ├── portal/ # Pages portail client
|
||||
│ └── projects/ # Pages projets
|
||||
├── layouts/ # Layouts (default, portal)
|
||||
├── components/ # Composants Vue
|
||||
│ ├── ui/ # Composants génériques
|
||||
│ ├── task/ # Tâches
|
||||
│ ├── user/ # Utilisateur (avatar, etc.)
|
||||
│ ├── project/ # Projets
|
||||
│ ├── client/ # Clients
|
||||
│ ├── client-ticket/ # Tickets client
|
||||
│ ├── admin/ # Administration
|
||||
│ ├── notification/ # Notifications
|
||||
│ └── time-tracking/ # Suivi du temps
|
||||
├── composables/ # Composables (useApi, useNotifications, etc.)
|
||||
├── stores/ # Stores Pinia (auth, ui, timer)
|
||||
├── services/ # Services API
|
||||
│ └── dto/ # Types TypeScript
|
||||
├── plugins/ # Plugins Nuxt
|
||||
├── utils/ # Utilitaires
|
||||
├── i18n/locales/ # Traductions
|
||||
└── middleware/ # Middleware auth
|
||||
|
||||
config/ # Config Symfony
|
||||
migrations/ # Migrations Doctrine
|
||||
docker/ # Dockerfiles et config Nginx
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
| Container | Port | Description |
|
||||
|-----------|------|-------------|
|
||||
| `php-lesstime-fpm` | 3002 (dev Nuxt) | PHP-FPM + Node 24 |
|
||||
| `nginx-lesstime` | 8082 | Nginx reverse proxy |
|
||||
| PostgreSQL | 5435 | Base de données |
|
||||
|
||||
Configuration : `docker/.env.docker` (override local : `docker/.env.docker.local`)
|
||||
|
||||
## API
|
||||
|
||||
Toutes les routes API sont préfixées `/api` (API Platform).
|
||||
|
||||
- Documentation auto-générée : **http://localhost:8082/api**
|
||||
- Auth : `POST /login_check` avec `{ username, password }` → cookie JWT `BEARER`
|
||||
|
||||
## Serveur MCP
|
||||
|
||||
Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistants IA d'interagir avec les données.
|
||||
|
||||
### Tools disponibles (22)
|
||||
|
||||
@@ -16,13 +179,6 @@ Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistant
|
||||
| TaskMeta | `list-statuses`, `list-priorities`, `list-efforts`, `list-tags`, `list-groups`, `create-group`, `update-group` |
|
||||
| TimeEntry | `list-time-entries`, `create-time-entry`, `update-time-entry`, `delete-time-entry` |
|
||||
|
||||
### Transports
|
||||
|
||||
| Transport | Usage | Auth |
|
||||
|-----------|-------|------|
|
||||
| **STDIO** | Claude Code sur la machine locale | Aucune |
|
||||
| **HTTP** (`/_mcp`) | Clients MCP sur le réseau local | API token (`Authorization: Bearer <token>`) |
|
||||
|
||||
### Configuration locale (STDIO)
|
||||
|
||||
```json
|
||||
@@ -55,17 +211,19 @@ Lesstime expose un serveur MCP (Model Context Protocol) permettant aux assistant
|
||||
### Gestion des tokens API
|
||||
|
||||
```bash
|
||||
# Générer un token pour un utilisateur
|
||||
docker exec -u www-data php-lesstime-fpm php bin/console app:generate-api-token <username>
|
||||
```
|
||||
|
||||
### Mise en production (réseau local)
|
||||
## Déploiement
|
||||
|
||||
1. Déployer le code sur le serveur
|
||||
2. `composer install --no-dev --optimize-autoloader`
|
||||
3. `php bin/console doctrine:migrations:migrate --no-interaction`
|
||||
4. `php bin/console cache:clear --env=prod`
|
||||
5. `docker restart nginx-lesstime`
|
||||
6. `php bin/console app:generate-api-token admin` — noter le token
|
||||
7. Ouvrir le port 8082 sur le firewall du serveur (LAN uniquement)
|
||||
8. Configurer les clients MCP avec l'URL `http://<ip-serveur>:8082/_mcp` + le token
|
||||
5. `cd frontend && npm install && npm run build:dist`
|
||||
6. `docker restart nginx-lesstime`
|
||||
7. Ouvrir le port 8082 sur le firewall (LAN uniquement)
|
||||
|
||||
## Licence
|
||||
|
||||
Propriétaire — Tous droits réservés.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.0'
|
||||
app.version: '0.2.0'
|
||||
|
||||
50
deploy/nginx/lesstime.conf
Normal file
50
deploy/nginx/lesstime.conf
Normal file
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
213
docs/deploy.md
Normal file
213
docs/deploy.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Deploiement sur serveur Ubuntu (sans Docker)
|
||||
|
||||
## Prerequis
|
||||
|
||||
- Ubuntu 22.04+ avec PHP 8.4, Node 24, PostgreSQL 16, Nginx
|
||||
- Acces root ou sudo sur le serveur
|
||||
|
||||
## 1. Preparer la BDD
|
||||
|
||||
```bash
|
||||
sudo -u postgres createuser lesstime
|
||||
sudo -u postgres createdb -O lesstime lesstime
|
||||
sudo -u postgres psql -c "ALTER USER lesstime WITH PASSWORD 'ton-mdp';"
|
||||
```
|
||||
|
||||
## 2. Creer les dossiers
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/lesstime/var/log /var/www/lesstime/var/cache /var/www/lesstime/config/jwt
|
||||
sudo chown -R www-data:www-data /var/www/lesstime
|
||||
```
|
||||
|
||||
## 3. Configurer l'environnement
|
||||
|
||||
```bash
|
||||
sudo nano /var/www/lesstime/.env
|
||||
```
|
||||
|
||||
Contenu minimal :
|
||||
```ini
|
||||
APP_ENV=prod
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo nano /var/www/lesstime/.env.local
|
||||
```
|
||||
|
||||
Contenu :
|
||||
```ini
|
||||
APP_ENV=prod
|
||||
APP_SECRET=<random-hex-32>
|
||||
APP_DEBUG=0
|
||||
|
||||
DEFAULT_URI=http://project.malio-dev.fr/
|
||||
CORS_ALLOW_ORIGIN='^https?://project\.malio-dev\.fr$'
|
||||
|
||||
DATABASE_URL="postgresql://lesstime:<mdp>@localhost:5432/lesstime?serverVersion=16&charset=utf8"
|
||||
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
JWT_PASSPHRASE=<passphrase>
|
||||
JWT_COOKIE_SECURE=0
|
||||
JWT_TOKEN_TTL=86400
|
||||
JWT_COOKIE_TTL=86400
|
||||
|
||||
ENCRYPTION_KEY=<random-hex-32>
|
||||
```
|
||||
|
||||
> `JWT_COOKIE_SECURE=0` car HTTP. Passer a `1` si HTTPS.
|
||||
|
||||
## 4. Installer le script de deploy
|
||||
|
||||
```bash
|
||||
sudo cp script/deploy-release.sh /usr/local/bin/deploy-lesstime
|
||||
sudo chmod +x /usr/local/bin/deploy-lesstime
|
||||
```
|
||||
|
||||
Si le repo Gitea est prive, configurer un token :
|
||||
```bash
|
||||
echo "ton-token-gitea" | sudo tee /etc/lesstime-release-token
|
||||
sudo chmod 600 /etc/lesstime-release-token
|
||||
```
|
||||
|
||||
## 5. Deployer une release
|
||||
|
||||
```bash
|
||||
sudo /usr/local/bin/deploy-lesstime v0.2.0
|
||||
```
|
||||
|
||||
Le script telecharge l'artefact, extrait les fichiers, clear le cache et lance les migrations.
|
||||
|
||||
## 6. Generer les cles JWT
|
||||
|
||||
```bash
|
||||
cd /var/www/lesstime
|
||||
sudo -u www-data php bin/console lexik:jwt:generate-keypair --skip-if-exists --env=prod
|
||||
```
|
||||
|
||||
## 7. Configurer Nginx
|
||||
|
||||
```bash
|
||||
sudo cp deploy/nginx/lesstime.conf /etc/nginx/sites-available/lesstime
|
||||
sudo ln -sf /etc/nginx/sites-available/lesstime /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## 8. Creer le premier user admin
|
||||
|
||||
Hasher un mot de passe :
|
||||
```bash
|
||||
php /var/www/lesstime/bin/console security:hash-password --env=prod
|
||||
```
|
||||
|
||||
Choisir `App\Entity\User`, taper le mdp, copier le hash. Puis :
|
||||
```bash
|
||||
sudo -u postgres psql lesstime -c "INSERT INTO \"user\" (username, roles, password, created_at) VALUES ('admin', '[\"ROLE_ADMIN\"]', '<le-hash>', NOW());"
|
||||
```
|
||||
|
||||
## 9. Tester
|
||||
|
||||
```bash
|
||||
curl http://project.malio-dev.fr/api/version
|
||||
curl http://project.malio-dev.fr/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Connecter le serveur MCP a Claude Code
|
||||
|
||||
Le serveur MCP expose 22 tools (projets, taches, time tracking avec liaison tickets client, metadonnees) via le endpoint HTTP `/_mcp`.
|
||||
|
||||
## 1. Generer un token API
|
||||
|
||||
Sur le serveur (ou en local via Docker) :
|
||||
|
||||
```bash
|
||||
# Production (serveur)
|
||||
php /var/www/lesstime/bin/console app:generate-api-token admin --env=prod
|
||||
|
||||
# Dev (Docker)
|
||||
docker exec -it php-lesstime-fpm php bin/console app:generate-api-token admin
|
||||
```
|
||||
|
||||
La commande affiche un token de 64 caracteres. Ce token est lie a l'utilisateur et stocke en base (champ `apiToken` de l'entite `User`).
|
||||
|
||||
## 2. Configurer Claude Code
|
||||
|
||||
### Transport HTTP (recommande pour la prod)
|
||||
|
||||
Creer ou modifier `.mcp.json` a la racine du projet :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"lesstime": {
|
||||
"type": "http",
|
||||
"url": "http://project.malio-dev.fr/_mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer <ton-token>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Transport STDIO (dev local via Docker)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"lesstime-local": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"exec",
|
||||
"-i",
|
||||
"php-lesstime-fpm",
|
||||
"php",
|
||||
"bin/console",
|
||||
"mcp:server"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Transport STDIO via SSH (prod sans endpoint HTTP)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"lesstime": {
|
||||
"command": "ssh",
|
||||
"args": [
|
||||
"user@serveur",
|
||||
"php",
|
||||
"/var/www/lesstime/bin/console",
|
||||
"mcp:server",
|
||||
"--env=prod"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Redemarrer Claude Code
|
||||
|
||||
Apres modification de `.mcp.json`, relancer Claude Code pour qu'il detecte le serveur.
|
||||
|
||||
## 4. Verifier
|
||||
|
||||
Demander a Claude d'utiliser un outil MCP, par exemple :
|
||||
- "Liste les projets sur Lesstime"
|
||||
- "Cree une tache dans le projet LT"
|
||||
|
||||
## Tools disponibles
|
||||
|
||||
| Domaine | Tools |
|
||||
|---------|-------|
|
||||
| Projets | list-projects, get-project, create-project, update-project |
|
||||
| Taches | list-tasks, get-task, create-task, update-task, delete-task |
|
||||
| Metadonnees | list-statuses, list-priorities, list-efforts, list-tags, list-groups, create-group, update-group |
|
||||
| Time tracking | list-time-entries, create-time-entry, update-time-entry, delete-time-entry (supporte clientTicketId) |
|
||||
| Reference | list-users, list-clients |
|
||||
@@ -34,6 +34,7 @@
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
type="button"
|
||||
class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block"
|
||||
@click.stop="$emit('delete', doc)"
|
||||
>
|
||||
|
||||
@@ -289,6 +289,7 @@ const isOpen = computed({
|
||||
})
|
||||
|
||||
function close() {
|
||||
if (confirmDeleteDocOpen.value || confirmDeleteOpen.value) return
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
@@ -390,6 +391,8 @@ function populateForm(task: Task | null) {
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (open) {
|
||||
confirmDeleteDocOpen.value = false
|
||||
documentToDelete.value = null
|
||||
populateForm(props.task)
|
||||
try {
|
||||
clientTickets.value = await clientTicketService.getAll({ project: props.projectId })
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Teleport v-if="modelValue" to="body">
|
||||
<Transition name="modal" appear>
|
||||
<div class="fixed inset-0 z-[70] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/30" @click="cancel" />
|
||||
<div class="absolute inset-0 bg-black/30" @click.stop="cancel" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('taskDocuments.confirmDeleteTitle') }}</h3>
|
||||
<p class="mt-3 text-sm text-neutral-600">
|
||||
|
||||
@@ -236,20 +236,20 @@ onMounted(() => {
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('myTasks.title') }}</h1>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="rounded-lg p-2 transition-colors"
|
||||
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
|
||||
:class="viewMode === 'kanban' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
||||
:title="$t('myTasks.viewKanban')"
|
||||
@click="viewMode = 'kanban'"
|
||||
>
|
||||
<Icon name="mdi:view-column-outline" size="20" />
|
||||
<Icon name="mdi:view-column-outline" size="18" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg p-2 transition-colors"
|
||||
class="flex items-center justify-center rounded-md p-1.5 transition-colors"
|
||||
:class="viewMode === 'list' ? 'bg-primary-500 text-white' : 'text-neutral-400 hover:text-primary-500'"
|
||||
:title="$t('myTasks.viewList')"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<Icon name="mdi:view-list-outline" size="20" />
|
||||
<Icon name="mdi:view-list-outline" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
35
migrations/Version20260316124157.php
Normal file
35
migrations/Version20260316124157.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?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 Version20260316124157 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('ALTER TABLE time_entry ADD client_ticket_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE time_entry ADD CONSTRAINT FK_6E537C0C9B2097DD FOREIGN KEY (client_ticket_id) REFERENCES client_ticket (id) ON DELETE SET NULL NOT DEFERRABLE');
|
||||
$this->addSql('CREATE INDEX IDX_6E537C0C9B2097DD ON time_entry (client_ticket_id)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE time_entry DROP CONSTRAINT FK_6E537C0C9B2097DD');
|
||||
$this->addSql('DROP INDEX IDX_6E537C0C9B2097DD');
|
||||
$this->addSql('ALTER TABLE time_entry DROP client_ticket_id');
|
||||
}
|
||||
}
|
||||
91
script/deploy-release.sh
Executable file
91
script/deploy-release.sh
Executable file
@@ -0,0 +1,91 @@
|
||||
#!/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}"
|
||||
|
||||
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
|
||||
@@ -85,6 +85,11 @@ class TimeEntry
|
||||
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||
private ?Task $task = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ClientTicket::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||
private ?ClientTicket $clientTicket = null;
|
||||
|
||||
/** @var Collection<int, TaskTag> */
|
||||
#[ORM\ManyToMany(targetEntity: TaskTag::class)]
|
||||
#[ORM\JoinTable(
|
||||
@@ -189,6 +194,18 @@ class TimeEntry
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getClientTicket(): ?ClientTicket
|
||||
{
|
||||
return $this->clientTicket;
|
||||
}
|
||||
|
||||
public function setClientTicket(?ClientTicket $clientTicket): static
|
||||
{
|
||||
$this->clientTicket = $clientTicket;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, TaskTag> */
|
||||
public function getTags(): Collection
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool;
|
||||
|
||||
use App\Entity\ClientTicket;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskDocument;
|
||||
@@ -239,22 +240,39 @@ final class Serializer
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|array{id: ?int, number: ?int, title: ?string}
|
||||
*/
|
||||
public static function clientTicketRef(?ClientTicket $ticket): ?array
|
||||
{
|
||||
if (null === $ticket) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $ticket->getId(),
|
||||
'number' => $ticket->getNumber(),
|
||||
'title' => $ticket->getTitle(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function timeEntry(TimeEntry $entry): array
|
||||
{
|
||||
return [
|
||||
'id' => $entry->getId(),
|
||||
'title' => $entry->getTitle(),
|
||||
'description' => $entry->getDescription(),
|
||||
'startedAt' => $entry->getStartedAt()?->format('c'),
|
||||
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
|
||||
'duration' => self::durationMinutes($entry),
|
||||
'user' => self::user($entry->getUser()),
|
||||
'project' => $entry->getProject() ? self::projectRef($entry->getProject()) : null,
|
||||
'task' => self::taskRef($entry->getTask()),
|
||||
'tags' => self::tags($entry->getTags()),
|
||||
'id' => $entry->getId(),
|
||||
'title' => $entry->getTitle(),
|
||||
'description' => $entry->getDescription(),
|
||||
'startedAt' => $entry->getStartedAt()?->format('c'),
|
||||
'stoppedAt' => $entry->getStoppedAt()?->format('c'),
|
||||
'duration' => self::durationMinutes($entry),
|
||||
'user' => self::user($entry->getUser()),
|
||||
'project' => $entry->getProject() ? self::projectRef($entry->getProject()) : null,
|
||||
'task' => self::taskRef($entry->getTask()),
|
||||
'clientTicket' => self::clientTicketRef($entry->getClientTicket()),
|
||||
'tags' => self::tags($entry->getTags()),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class CreateTaskTool
|
||||
$task = new Task();
|
||||
$task->setProject($project);
|
||||
$task->setTitle($title);
|
||||
$task->setNumber($this->taskRepository->findMaxNumberByProject($project) + 1);
|
||||
$task->setNumber($this->taskRepository->findMaxNumberByProjectForUpdate($project) + 1);
|
||||
|
||||
if (null !== $description) {
|
||||
$task->setDescription($description);
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Mcp\Tool\TimeEntry;
|
||||
|
||||
use App\Entity\TimeEntry;
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Repository\ClientTicketRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Repository\TaskTagRepository;
|
||||
@@ -28,6 +29,7 @@ class CreateTimeEntryTool
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly TimeEntryRepository $timeEntryRepository,
|
||||
private readonly ClientTicketRepository $clientTicketRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
@@ -39,6 +41,7 @@ class CreateTimeEntryTool
|
||||
?int $taskId = null,
|
||||
?array $tagIds = null,
|
||||
?string $description = null,
|
||||
?int $clientTicketId = null,
|
||||
): string {
|
||||
$user = $this->userRepository->find($userId);
|
||||
if (null === $user) {
|
||||
@@ -80,6 +83,13 @@ class CreateTimeEntryTool
|
||||
}
|
||||
$entry->setTask($task);
|
||||
}
|
||||
if (null !== $clientTicketId) {
|
||||
$clientTicket = $this->clientTicketRepository->find($clientTicketId);
|
||||
if (null === $clientTicket) {
|
||||
throw new InvalidArgumentException(sprintf('ClientTicket with ID %d not found.', $clientTicketId));
|
||||
}
|
||||
$entry->setClientTicket($clientTicket);
|
||||
}
|
||||
if (null !== $tagIds) {
|
||||
foreach ($tagIds as $tagId) {
|
||||
$tag = $this->taskTagRepository->find($tagId);
|
||||
|
||||
@@ -20,6 +20,7 @@ class ListTimeEntriesTool
|
||||
?int $userId = null,
|
||||
?int $projectId = null,
|
||||
?int $taskId = null,
|
||||
?int $clientTicketId = null,
|
||||
?string $startDate = null,
|
||||
?string $endDate = null,
|
||||
int $limit = 100,
|
||||
@@ -31,6 +32,7 @@ class ListTimeEntriesTool
|
||||
->leftJoin('te.project', 'p')->addSelect('p')
|
||||
->leftJoin('te.task', 't')->addSelect('t')
|
||||
->leftJoin('te.tags', 'tg')->addSelect('tg')
|
||||
->leftJoin('te.clientTicket', 'ct')->addSelect('ct')
|
||||
->orderBy('te.startedAt', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
;
|
||||
@@ -44,6 +46,9 @@ class ListTimeEntriesTool
|
||||
if (null !== $taskId) {
|
||||
$qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId);
|
||||
}
|
||||
if (null !== $clientTicketId) {
|
||||
$qb->andWhere('ct.id = :clientTicketId')->setParameter('clientTicketId', $clientTicketId);
|
||||
}
|
||||
if (null !== $startDate) {
|
||||
$qb->andWhere('te.startedAt >= :startDate')
|
||||
->setParameter('startDate', new DateTimeImmutable($startDate.' 00:00:00'))
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Mcp\Tool\TimeEntry;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Repository\ClientTicketRepository;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Repository\TaskTagRepository;
|
||||
@@ -24,6 +25,7 @@ class UpdateTimeEntryTool
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly ClientTicketRepository $clientTicketRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
@@ -36,6 +38,7 @@ class UpdateTimeEntryTool
|
||||
?int $taskId = null,
|
||||
?array $tagIds = null,
|
||||
?string $description = null,
|
||||
?int $clientTicketId = null,
|
||||
): string {
|
||||
$entry = $this->timeEntryRepository->find($id);
|
||||
|
||||
@@ -69,6 +72,13 @@ class UpdateTimeEntryTool
|
||||
}
|
||||
$entry->setTask($task);
|
||||
}
|
||||
if (null !== $clientTicketId) {
|
||||
$clientTicket = $this->clientTicketRepository->find($clientTicketId);
|
||||
if (null === $clientTicket) {
|
||||
throw new InvalidArgumentException(sprintf('ClientTicket with ID %d not found.', $clientTicketId));
|
||||
}
|
||||
$entry->setClientTicket($clientTicket);
|
||||
}
|
||||
if (null !== $tagIds) {
|
||||
foreach ($entry->getTags()->toArray() as $existingTag) {
|
||||
$entry->removeTag($existingTag);
|
||||
|
||||
@@ -28,7 +28,7 @@ class TaskRepository extends ServiceEntityRepository
|
||||
$conn = $this->getEntityManager()->getConnection();
|
||||
|
||||
$result = $conn->fetchOne(
|
||||
'SELECT COALESCE(MAX(number), 0) FROM task WHERE project_id = :project FOR UPDATE',
|
||||
'SELECT COALESCE(MAX(number), 0) FROM task WHERE project_id = :project',
|
||||
['project' => $project->getId()],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user