Compare commits

...

8 Commits

Author SHA1 Message Date
Matthieu
0180dd3715 chore : bump version to 0.2.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m32s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:41:38 +01:00
Matthieu
0f99098291 chore : bump version to 0.2.0 and update deploy doc
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Failing after 1m25s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:29:41 +01:00
Matthieu
1c6f473dff feat(mcp) : add clientTicket relation to time entries
Add ManyToOne relation from TimeEntry to ClientTicket entity.
MCP tools create-time-entry, update-time-entry, and list-time-entries
now support clientTicketId parameter for linking tickets to time entries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:28:31 +01:00
Matthieu
c95fff530c docs(deploy) : add deployment guide and MCP connection tutorial
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:28:31 +01:00
gitea-actions
fb0e6c1ea4 chore: bump version to v0.1.2
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m19s
2026-03-16 08:52:02 +00:00
Matthieu
6d3ecc1322 Merge branch 'feature/client-portal' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-16 09:51:48 +01:00
Matthieu
f5986090c0 feat(deploy) : add deploy script and nginx config for bare Ubuntu server
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:51:29 +01:00
gitea-actions
4216f1b5a1 chore: bump version to v0.1.1
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
2026-03-15 18:07:23 +00:00
11 changed files with 461 additions and 11 deletions

View File

@@ -45,6 +45,7 @@ jobs:
set -euo pipefail
mkdir -p release
tar -czf "release/lesstime-${GITHUB_REF_NAME}.tar.gz" \
.env \
bin \
config \
migrations \

View File

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

View 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
View 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.1
```
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 |

View 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
View 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

View File

@@ -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
{

View File

@@ -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()),
];
}

View File

@@ -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);

View File

@@ -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'))

View File

@@ -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);