Initial commit - SOGOMS v1.0.0
- sogoctl: supervisor avec health checks et restart auto - sogoway: gateway HTTP, auth JWT, routing par hostname - sogoms-db: microservice MariaDB avec pool par application - Protocol IPC Unix socket JSON length-prefixed - Config YAML multi-application (prokov) - Deploy script pour container Alpine gw3 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Binaries
|
||||||
|
bin/
|
||||||
|
*.exe
|
||||||
|
sogoctl
|
||||||
|
sogoway
|
||||||
|
sogoms-db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Secrets (never commit)
|
||||||
|
/secrets/
|
||||||
|
|
||||||
|
# Temp
|
||||||
|
*.tar.gz
|
||||||
46
CLAUDE.md
Executable file
46
CLAUDE.md
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Règles de travail
|
||||||
|
|
||||||
|
- Soit concis
|
||||||
|
- Ne fais rien sans validation utilisateur
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
SOGOMS (Service Oriented GO MicroServices) - plateforme SaaS modulaire, multi-tenant, configurable.
|
||||||
|
|
||||||
|
**Principes:** modularité (1 feature = 1 binaire Go), configuration YAML, multi-tenant natif, un container avec plusieurs processus supervisés.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| Binaire | Rôle | Port/Socket |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| **sogoctl** | Superviseur PID 1, health checks, scaling | TCP :9000 |
|
||||||
|
| **sogoway** | Gateway HTTP, auth JWT, routing | TCP :8080 |
|
||||||
|
| **sogorch** | Orchestrateur scénarios YAML | Unix socket |
|
||||||
|
| **sogoms-db** | Accès MariaDB | Unix socket |
|
||||||
|
| **sogoms-pdf** | Génération PDF | Unix socket |
|
||||||
|
| **sogoms-email** | Envoi emails | Unix socket |
|
||||||
|
| **sogoms-storage** | Gestion fichiers | Unix socket |
|
||||||
|
|
||||||
|
**Flux:** Client → Nginx(:443) → Sogoway(:8080) → Sogorch → Sogoms-*
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/{sogoctl,sogoway,sogorch}/main.go
|
||||||
|
cmd/sogoms/{db,pdf,email,storage}/main.go
|
||||||
|
internal/{protocol,pool,config,scenario,auth,registry}/
|
||||||
|
config/{sogoctl,sogoway}.yaml
|
||||||
|
config/{tenants,routes,scenarios}/*.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
|
||||||
|
Unix socket JSON length-prefixed (4 bytes length + JSON payload).
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
net/http standard (Go 1.22+), go-yaml/v3, go-sql-driver/mysql, chromedp (PDF)
|
||||||
97
README.md
Normal file
97
README.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# SOGOMS
|
||||||
|
|
||||||
|
**Service Oriented GO MicroServices** - Plateforme SaaS modulaire multi-tenant.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Client → Nginx(:443) → Sogoway(:8080) → Sogoms-db → MariaDB
|
||||||
|
↓
|
||||||
|
Unix Socket
|
||||||
|
```
|
||||||
|
|
||||||
|
| Binaire | Rôle | Port/Socket |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| `sogoctl` | Superviseur PID 1, health checks, restart auto | - |
|
||||||
|
| `sogoway` | Gateway HTTP, auth JWT, routing par hostname | TCP :8080 |
|
||||||
|
| `sogoms-db` | Accès MariaDB, pool par application | Unix socket |
|
||||||
|
|
||||||
|
## Déploiement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Déploie sur le container `gw3` (Alpine) via IN3.
|
||||||
|
|
||||||
|
## Lancement
|
||||||
|
|
||||||
|
Sur gw3 :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/sogoms/bin/sogoctl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Chaque application cliente a son fichier dans `config/routes/` :
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config/routes/prokov.yaml
|
||||||
|
app: prokov
|
||||||
|
hosts:
|
||||||
|
- prokov.unikoffice.com
|
||||||
|
database:
|
||||||
|
host: 13.23.33.4
|
||||||
|
user: prokov_user
|
||||||
|
password_file: /secrets/prokov_db_pass
|
||||||
|
name: prokov
|
||||||
|
auth:
|
||||||
|
jwt_secret_file: /secrets/prokov_jwt_secret
|
||||||
|
jwt_expiry: 24h
|
||||||
|
```
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/
|
||||||
|
sogoctl/main.go # Superviseur
|
||||||
|
sogoway/main.go # Gateway HTTP
|
||||||
|
sogoms/db/main.go # Microservice DB
|
||||||
|
internal/
|
||||||
|
protocol/ # IPC Unix socket (JSON length-prefixed)
|
||||||
|
config/ # Chargement YAML, registry par host
|
||||||
|
auth/ # JWT (HS256), bcrypt passwords
|
||||||
|
config/
|
||||||
|
sogoctl.yaml # Services à superviser
|
||||||
|
routes/*.yaml # Config par application
|
||||||
|
scenarios/ # Scénarios YAML (V2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:8080/health
|
||||||
|
|
||||||
|
# Login
|
||||||
|
curl -X POST http://localhost:8080/api/auth/login \
|
||||||
|
-H "Host: prokov.unikoffice.com" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"user@example.com","password":"secret"}'
|
||||||
|
|
||||||
|
# User info (avec token)
|
||||||
|
curl http://localhost:8080/api/auth/me \
|
||||||
|
-H "Host: prokov.unikoffice.com" \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
- Go 1.22+
|
||||||
|
- MariaDB/MySQL
|
||||||
|
- Container Alpine (gw3)
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Propriétaire
|
||||||
92
TODO.md
Executable file
92
TODO.md
Executable file
@@ -0,0 +1,92 @@
|
|||||||
|
# TODO - SOGOMS V1 Minimale
|
||||||
|
|
||||||
|
Objectif : valider l'architecture avec 2-3 microservices basiques.
|
||||||
|
|
||||||
|
## Phase 0 : Infrastructure
|
||||||
|
|
||||||
|
- [x] **Container gw3** : Alpine sur IN3 (13.23.33.5)
|
||||||
|
- [x] **Config Prokov** : routes + scénarios YAML (auth, projects, tasks, tags, statuses)
|
||||||
|
- [x] **Nginx host IN3** : routing /api/ → gw3:8080, / → dva-front
|
||||||
|
|
||||||
|
## Phase 1 : Protocole IPC
|
||||||
|
|
||||||
|
- [x] `internal/protocol/message.go` : structs Request/Response JSON
|
||||||
|
- [x] `internal/protocol/server.go` : listener Unix socket
|
||||||
|
- [x] `internal/protocol/client.go` : client pour appeler les services
|
||||||
|
|
||||||
|
## Phase 2 : Microservice DB
|
||||||
|
|
||||||
|
- [x] `cmd/sogoms/db/main.go` : point d'entrée
|
||||||
|
- [x] Connexion MariaDB (pool par application)
|
||||||
|
- [x] Action `query` : SELECT multi-résultats
|
||||||
|
- [x] Action `query_one` : SELECT un résultat
|
||||||
|
- [x] Action `insert` : INSERT retourne insert_id
|
||||||
|
- [x] Action `update` : UPDATE retourne affected_rows
|
||||||
|
- [x] Action `delete` : DELETE retourne affected_rows
|
||||||
|
- [x] Écoute sur `/run/sogoms-db.1.sock`
|
||||||
|
- [x] Test standalone sogoms-db
|
||||||
|
|
||||||
|
## Phase 3 : Config
|
||||||
|
|
||||||
|
- [x] `internal/config/config.go` : lecture YAML + registry par host
|
||||||
|
- [x] `internal/config/routes.go` : parser routes (intégré dans config.go)
|
||||||
|
|
||||||
|
## Phase 4 : Gateway HTTP
|
||||||
|
|
||||||
|
- [x] `cmd/sogoway/main.go` : serveur HTTP :8080
|
||||||
|
- [x] Routing par host → charge le bon fichier routes (prokov.yaml)
|
||||||
|
- [x] `internal/auth/jwt.go` : génération + validation JWT (HS256)
|
||||||
|
- [x] `internal/auth/password.go` : hash + verify password (bcrypt)
|
||||||
|
- [x] Endpoint `POST /api/auth/login` : vérifie credentials, retourne JWT
|
||||||
|
- [x] Endpoint `GET /api/auth/me` : valide JWT, retourne user
|
||||||
|
- [x] Endpoint `POST /api/auth/register` : crée user, retourne JWT
|
||||||
|
- [x] Communication avec sogoms-db via Unix socket
|
||||||
|
- [x] Test standalone sogoway
|
||||||
|
|
||||||
|
## Phase 5 : Superviseur
|
||||||
|
|
||||||
|
- [x] `cmd/sogoctl/main.go` : point d'entrée
|
||||||
|
- [x] Config `config/sogoctl.yaml` : services à lancer
|
||||||
|
- [x] Lancement sogoms-db + sogoway (avec dépendances)
|
||||||
|
- [x] Health check (socket + HTTP)
|
||||||
|
- [x] Redémarrage automatique si crash
|
||||||
|
|
||||||
|
## Phase 6 : Test de validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Lancer sogoctl (démarre les services)
|
||||||
|
./sogoctl
|
||||||
|
|
||||||
|
# 2. Login
|
||||||
|
curl -X POST https://prokov.unikoffice.com/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"email":"test@example.com","password":"secret"}'
|
||||||
|
# → {"success":true,"data":{"token":"eyJ...","user":{...}}}
|
||||||
|
|
||||||
|
# 3. Vérifier le token
|
||||||
|
curl https://prokov.unikoffice.com/api/auth/me \
|
||||||
|
-H "Authorization: Bearer eyJ..."
|
||||||
|
# → {"success":true,"data":{"user":{...}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] Test login OK
|
||||||
|
- [x] Test /me avec token valide OK
|
||||||
|
- [x] Test /me sans token → 401
|
||||||
|
|
||||||
|
## Phase 7 : Microservice Logs
|
||||||
|
|
||||||
|
- [ ] `cmd/sogoms/logs/main.go` : point d'entrée
|
||||||
|
- [ ] Écoute sur Unix socket `/run/sogoms-logs.1.sock`
|
||||||
|
- [ ] Actions `log_error`, `log_event` : écriture dans fichiers
|
||||||
|
- [ ] Format fichiers : `/var/log/sogoms/{app}-{YYYYMMDD}-{type}.log`
|
||||||
|
- [ ] Rotation automatique : suppression des fichiers > N jours (défaut 15)
|
||||||
|
- [ ] Paramètre `retention_days` dans config
|
||||||
|
- [ ] Intégration avec sogoway et sogoms-db
|
||||||
|
|
||||||
|
## Hors scope V1
|
||||||
|
|
||||||
|
- sogorch (orchestrateur scénarios)
|
||||||
|
- sogoms-pdf, sogoms-email, sogoms-storage
|
||||||
|
- Multi-tenant
|
||||||
|
- Rate limiting
|
||||||
|
- Exécution dynamique des scénarios YAML
|
||||||
39
clients/prokov/api/config/Database.php
Normal file
39
clients/prokov/api/config/Database.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Connexion à la base de données (Singleton)
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
private static ?PDO $instance = null;
|
||||||
|
|
||||||
|
public static function getInstance(): PDO
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
try {
|
||||||
|
self::$instance = new PDO(
|
||||||
|
sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME),
|
||||||
|
DB_USER,
|
||||||
|
DB_PASS,
|
||||||
|
[
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
Response::error('Database connection failed', 500);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empêcher le clonage et la désérialisation
|
||||||
|
private function __construct() {}
|
||||||
|
private function __clone() {}
|
||||||
|
public function __wakeup() {}
|
||||||
|
}
|
||||||
27
clients/prokov/api/config/config.php
Normal file
27
clients/prokov/api/config/config.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Configuration de l'application
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Environnement : 'dev' ou 'prod'
|
||||||
|
define('APP_ENV', 'dev');
|
||||||
|
|
||||||
|
// Base de données
|
||||||
|
define('DB_HOST', '13.23.33.4'); // container incus maria3
|
||||||
|
define('DB_NAME', 'prokov');
|
||||||
|
define('DB_USER', 'prokov_user');
|
||||||
|
define('DB_PASS', 'CHANGE_ME_PASSWORD');
|
||||||
|
|
||||||
|
// Session
|
||||||
|
define('SESSION_LIFETIME', 86400 * 7); // 7 jours
|
||||||
|
|
||||||
|
// Debug
|
||||||
|
if (APP_ENV === 'dev') {
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', '1');
|
||||||
|
} else {
|
||||||
|
error_reporting(0);
|
||||||
|
ini_set('display_errors', '0');
|
||||||
|
}
|
||||||
154
clients/prokov/api/controllers/AuthController.php
Normal file
154
clients/prokov/api/controllers/AuthController.php
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Contrôleur d'authentification
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* POST /auth/register
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$data = $this->validate([
|
||||||
|
'email' => 'required|email|max:255',
|
||||||
|
'password' => 'required|min:6|max:255',
|
||||||
|
'name' => 'required|min:2|max:100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Vérifier si l'email existe déjà
|
||||||
|
$stmt = $db->prepare('SELECT id FROM users WHERE email = :email');
|
||||||
|
$stmt->execute(['email' => $data['email']]);
|
||||||
|
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
Response::error('Cet email est déjà utilisé', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer l'utilisateur
|
||||||
|
$hashedPassword = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
INSERT INTO users (email, password, name)
|
||||||
|
VALUES (:email, :password, :name)
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'email' => $data['email'],
|
||||||
|
'password' => $hashedPassword,
|
||||||
|
'name' => $data['name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$userId = (int) $db->lastInsertId();
|
||||||
|
|
||||||
|
// Créer les statuts par défaut pour ce nouvel utilisateur
|
||||||
|
$this->createDefaultStatuses($userId);
|
||||||
|
|
||||||
|
// Créer une session
|
||||||
|
$sessionId = Session::create($userId);
|
||||||
|
|
||||||
|
Response::success([
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'user' => [
|
||||||
|
'id' => $userId,
|
||||||
|
'email' => $data['email'],
|
||||||
|
'name' => $data['name'],
|
||||||
|
],
|
||||||
|
], 'Inscription réussie', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/login
|
||||||
|
*/
|
||||||
|
public function login(): void
|
||||||
|
{
|
||||||
|
$data = $this->validate([
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => 'required',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('SELECT id, email, name, password FROM users WHERE email = :email');
|
||||||
|
$stmt->execute(['email' => $data['email']]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$user || !password_verify($data['password'], $user['password'])) {
|
||||||
|
Response::error('Email ou mot de passe incorrect', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer une session
|
||||||
|
$sessionId = Session::create($user['id']);
|
||||||
|
|
||||||
|
Response::success([
|
||||||
|
'session_id' => $sessionId,
|
||||||
|
'user' => [
|
||||||
|
'id' => $user['id'],
|
||||||
|
'email' => $user['email'],
|
||||||
|
'name' => $user['name'],
|
||||||
|
],
|
||||||
|
], 'Connexion réussie');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/logout
|
||||||
|
*/
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
$sessionId = $this->request->getSessionId();
|
||||||
|
|
||||||
|
if ($sessionId) {
|
||||||
|
Session::destroy($sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::success(null, 'Déconnexion réussie');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/me
|
||||||
|
*/
|
||||||
|
public function me(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
Response::success([
|
||||||
|
'user' => $this->user,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Créer les statuts par défaut pour un nouvel utilisateur
|
||||||
|
*/
|
||||||
|
private function createDefaultStatuses(int $userId): void
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$defaultStatuses = [
|
||||||
|
['code' => 10, 'name' => 'Backlog', 'color' => '#6B7280', 'position' => 10],
|
||||||
|
['code' => 20, 'name' => 'À faire', 'color' => '#3B82F6', 'position' => 20],
|
||||||
|
['code' => 30, 'name' => 'En cours', 'color' => '#F59E0B', 'position' => 30],
|
||||||
|
['code' => 40, 'name' => 'À tester', 'color' => '#8B5CF6', 'position' => 40],
|
||||||
|
['code' => 50, 'name' => 'Livré', 'color' => '#10B981', 'position' => 50],
|
||||||
|
['code' => 60, 'name' => 'Terminé', 'color' => '#059669', 'position' => 60],
|
||||||
|
['code' => 70, 'name' => 'Archivé', 'color' => '#9CA3AF', 'position' => 70],
|
||||||
|
];
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
INSERT INTO statuses (user_id, project_id, code, name, color, position)
|
||||||
|
VALUES (:user_id, NULL, :code, :name, :color, :position)
|
||||||
|
');
|
||||||
|
|
||||||
|
foreach ($defaultStatuses as $status) {
|
||||||
|
$stmt->execute([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'code' => $status['code'],
|
||||||
|
'name' => $status['name'],
|
||||||
|
'color' => $status['color'],
|
||||||
|
'position' => $status['position'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
359
clients/prokov/api/controllers/ProjectController.php
Normal file
359
clients/prokov/api/controllers/ProjectController.php
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Contrôleur des projets
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class ProjectController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /projects
|
||||||
|
* Liste tous les projets de l'utilisateur (arborescence)
|
||||||
|
*/
|
||||||
|
public function index(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Récupérer tous les projets de l'utilisateur
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT p.*,
|
||||||
|
GROUP_CONCAT(t.id) as tag_ids,
|
||||||
|
GROUP_CONCAT(t.name) as tag_names
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN project_tags pt ON p.id = pt.project_id
|
||||||
|
LEFT JOIN tags t ON pt.tag_id = t.id
|
||||||
|
WHERE p.user_id = :user_id
|
||||||
|
GROUP BY p.id
|
||||||
|
ORDER BY p.parent_id ASC, p.position ASC, p.name ASC
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute(['user_id' => $this->getUserId()]);
|
||||||
|
$projects = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Construire l'arborescence
|
||||||
|
$tree = $this->buildTree($projects);
|
||||||
|
|
||||||
|
Response::success($tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /projects/{id}
|
||||||
|
*/
|
||||||
|
public function show(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$id = (int) $this->request->getParam('id');
|
||||||
|
$project = $this->findOrFail($id);
|
||||||
|
|
||||||
|
// Récupérer les tags
|
||||||
|
$project['tags'] = $this->getProjectTags($id);
|
||||||
|
|
||||||
|
// Récupérer les sous-projets
|
||||||
|
$project['children'] = $this->getChildren($id);
|
||||||
|
|
||||||
|
Response::success($project);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /projects
|
||||||
|
*/
|
||||||
|
public function store(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$data = $this->validate([
|
||||||
|
'name' => 'required|min:1|max:100',
|
||||||
|
'description' => 'max:65535',
|
||||||
|
'parent_id' => 'int',
|
||||||
|
'position' => 'int',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Vérifier que le parent appartient à l'utilisateur
|
||||||
|
if (!empty($data['parent_id'])) {
|
||||||
|
$this->findOrFail((int) $data['parent_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
INSERT INTO projects (user_id, parent_id, name, description, position)
|
||||||
|
VALUES (:user_id, :parent_id, :name, :description, :position)
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'user_id' => $this->getUserId(),
|
||||||
|
'parent_id' => $data['parent_id'] ?: null,
|
||||||
|
'name' => $data['name'],
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'position' => $data['position'] ?? 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$projectId = (int) $db->lastInsertId();
|
||||||
|
|
||||||
|
// Gérer les tags si fournis
|
||||||
|
$tags = $this->request->get('tags');
|
||||||
|
if (is_array($tags)) {
|
||||||
|
$this->syncTags($projectId, $tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
$project = $this->findOrFail($projectId);
|
||||||
|
$project['tags'] = $this->getProjectTags($projectId);
|
||||||
|
|
||||||
|
Response::success($project, 'Projet créé', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /projects/{id}
|
||||||
|
*/
|
||||||
|
public function update(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$id = (int) $this->request->getParam('id');
|
||||||
|
$this->findOrFail($id);
|
||||||
|
|
||||||
|
$data = $this->validate([
|
||||||
|
'name' => 'min:1|max:100',
|
||||||
|
'description' => 'max:65535',
|
||||||
|
'parent_id' => 'int',
|
||||||
|
'position' => 'int',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Vérifier que le parent n'est pas le projet lui-même ou un de ses enfants
|
||||||
|
if (!empty($data['parent_id'])) {
|
||||||
|
$parentId = (int) $data['parent_id'];
|
||||||
|
if ($parentId === $id) {
|
||||||
|
Response::error('Un projet ne peut pas être son propre parent', 422);
|
||||||
|
}
|
||||||
|
$this->findOrFail($parentId);
|
||||||
|
|
||||||
|
// Vérifier que le parent n'est pas un enfant du projet
|
||||||
|
if ($this->isDescendant($parentId, $id)) {
|
||||||
|
Response::error('Le parent ne peut pas être un sous-projet', 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
$params = ['id' => $id];
|
||||||
|
|
||||||
|
if (isset($data['name'])) {
|
||||||
|
$fields[] = 'name = :name';
|
||||||
|
$params['name'] = $data['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('description', $data)) {
|
||||||
|
$fields[] = 'description = :description';
|
||||||
|
$params['description'] = $data['description'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('parent_id', $data)) {
|
||||||
|
$fields[] = 'parent_id = :parent_id';
|
||||||
|
$params['parent_id'] = $data['parent_id'] ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['position'])) {
|
||||||
|
$fields[] = 'position = :position';
|
||||||
|
$params['position'] = $data['position'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($fields)) {
|
||||||
|
$sql = 'UPDATE projects SET ' . implode(', ', $fields) . ' WHERE id = :id';
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gérer les tags si fournis
|
||||||
|
$tags = $this->request->get('tags');
|
||||||
|
if (is_array($tags)) {
|
||||||
|
$this->syncTags($id, $tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
$project = $this->findOrFail($id);
|
||||||
|
$project['tags'] = $this->getProjectTags($id);
|
||||||
|
|
||||||
|
Response::success($project, 'Projet mis à jour');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /projects/{id}
|
||||||
|
*/
|
||||||
|
public function destroy(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$id = (int) $this->request->getParam('id');
|
||||||
|
$this->findOrFail($id);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Les sous-projets et tâches seront supprimés en cascade (FK)
|
||||||
|
$stmt = $db->prepare('DELETE FROM projects WHERE id = :id');
|
||||||
|
$stmt->execute(['id' => $id]);
|
||||||
|
|
||||||
|
Response::success(null, 'Projet supprimé');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouver un projet ou retourner 404
|
||||||
|
*/
|
||||||
|
private function findOrFail(int $id): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT * FROM projects
|
||||||
|
WHERE id = :id AND user_id = :user_id
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'id' => $id,
|
||||||
|
'user_id' => $this->getUserId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$project = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$project) {
|
||||||
|
Response::notFound('Projet non trouvé');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construire l'arborescence des projets
|
||||||
|
*/
|
||||||
|
private function buildTree(array $projects, ?int $parentId = null): array
|
||||||
|
{
|
||||||
|
$tree = [];
|
||||||
|
|
||||||
|
foreach ($projects as $project) {
|
||||||
|
if ($project['parent_id'] == $parentId) {
|
||||||
|
// Parser les tags
|
||||||
|
$project['tags'] = [];
|
||||||
|
if (!empty($project['tag_ids'])) {
|
||||||
|
$ids = explode(',', $project['tag_ids']);
|
||||||
|
$names = explode(',', $project['tag_names']);
|
||||||
|
foreach ($ids as $i => $tagId) {
|
||||||
|
$project['tags'][] = [
|
||||||
|
'id' => (int) $tagId,
|
||||||
|
'name' => $names[$i] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($project['tag_ids'], $project['tag_names']);
|
||||||
|
|
||||||
|
// Récursion pour les enfants
|
||||||
|
$project['children'] = $this->buildTree($projects, (int) $project['id']);
|
||||||
|
|
||||||
|
$tree[] = $project;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupérer les tags d'un projet
|
||||||
|
*/
|
||||||
|
private function getProjectTags(int $projectId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT t.id, t.name, t.color
|
||||||
|
FROM tags t
|
||||||
|
JOIN project_tags pt ON t.id = pt.tag_id
|
||||||
|
WHERE pt.project_id = :project_id
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute(['project_id' => $projectId]);
|
||||||
|
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupérer les sous-projets directs
|
||||||
|
*/
|
||||||
|
private function getChildren(int $projectId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT * FROM projects
|
||||||
|
WHERE parent_id = :parent_id AND user_id = :user_id
|
||||||
|
ORDER BY position ASC, name ASC
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'parent_id' => $projectId,
|
||||||
|
'user_id' => $this->getUserId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifier si un projet est un descendant d'un autre
|
||||||
|
*/
|
||||||
|
private function isDescendant(int $projectId, int $ancestorId): bool
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT parent_id FROM projects
|
||||||
|
WHERE id = :id AND user_id = :user_id
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'id' => $projectId,
|
||||||
|
'user_id' => $this->getUserId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$project = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$project || $project['parent_id'] === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $project['parent_id'] === $ancestorId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->isDescendant((int) $project['parent_id'], $ancestorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchroniser les tags d'un projet
|
||||||
|
*/
|
||||||
|
private function syncTags(int $projectId, array $tagIds): void
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Supprimer les associations existantes
|
||||||
|
$stmt = $db->prepare('DELETE FROM project_tags WHERE project_id = :project_id');
|
||||||
|
$stmt->execute(['project_id' => $projectId]);
|
||||||
|
|
||||||
|
// Ajouter les nouvelles associations
|
||||||
|
if (!empty($tagIds)) {
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
INSERT INTO project_tags (project_id, tag_id)
|
||||||
|
SELECT :project_id, id FROM tags
|
||||||
|
WHERE id = :tag_id AND user_id = :user_id
|
||||||
|
');
|
||||||
|
|
||||||
|
foreach ($tagIds as $tagId) {
|
||||||
|
$stmt->execute([
|
||||||
|
'project_id' => $projectId,
|
||||||
|
'tag_id' => (int) $tagId,
|
||||||
|
'user_id' => $this->getUserId(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
231
clients/prokov/api/controllers/StatusController.php
Normal file
231
clients/prokov/api/controllers/StatusController.php
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Contrôleur des statuts
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class StatusController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /statuses
|
||||||
|
* ?project_id=X - statuts d'un projet spécifique
|
||||||
|
* ?global=1 - uniquement les statuts globaux
|
||||||
|
*/
|
||||||
|
public function index(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$where = ['user_id = :user_id'];
|
||||||
|
$params = ['user_id' => $this->getUserId()];
|
||||||
|
|
||||||
|
$projectId = $this->request->get('project_id');
|
||||||
|
$globalOnly = $this->request->get('global');
|
||||||
|
|
||||||
|
if ($projectId !== null) {
|
||||||
|
// Statuts du projet + statuts globaux
|
||||||
|
$where = ['user_id = :user_id AND (project_id = :project_id OR project_id IS NULL)'];
|
||||||
|
$params['project_id'] = (int) $projectId;
|
||||||
|
} elseif ($globalOnly !== null) {
|
||||||
|
$where[] = 'project_id IS NULL';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = '
|
||||||
|
SELECT s.*,
|
||||||
|
(SELECT COUNT(*) FROM tasks t WHERE t.status_id = s.id) as task_count
|
||||||
|
FROM statuses s
|
||||||
|
WHERE ' . implode(' AND ', $where) . '
|
||||||
|
ORDER BY s.position ASC, s.code ASC
|
||||||
|
';
|
||||||
|
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$statuses = $stmt->fetchAll();
|
||||||
|
|
||||||
|
Response::success($statuses);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /statuses/{id}
|
||||||
|
*/
|
||||||
|
public function show(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$id = (int) $this->request->getParam('id');
|
||||||
|
$status = $this->findOrFail($id);
|
||||||
|
|
||||||
|
// Nombre de tâches avec ce statut
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$stmt = $db->prepare('SELECT COUNT(*) as count FROM tasks WHERE status_id = :id');
|
||||||
|
$stmt->execute(['id' => $id]);
|
||||||
|
$status['task_count'] = (int) $stmt->fetch()['count'];
|
||||||
|
|
||||||
|
Response::success($status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /statuses
|
||||||
|
*/
|
||||||
|
public function store(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$data = $this->validate([
|
||||||
|
'code' => 'required|int',
|
||||||
|
'name' => 'required|min:1|max:50',
|
||||||
|
'color' => 'max:7',
|
||||||
|
'project_id' => 'int',
|
||||||
|
'position' => 'int',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Si project_id fourni, vérifier qu'il appartient à l'utilisateur
|
||||||
|
if (!empty($data['project_id'])) {
|
||||||
|
$this->verifyProject((int) $data['project_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
INSERT INTO statuses (user_id, project_id, code, name, color, position)
|
||||||
|
VALUES (:user_id, :project_id, :code, :name, :color, :position)
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'user_id' => $this->getUserId(),
|
||||||
|
'project_id' => $data['project_id'] ?: null,
|
||||||
|
'code' => $data['code'],
|
||||||
|
'name' => $data['name'],
|
||||||
|
'color' => $data['color'] ?? '#6B7280',
|
||||||
|
'position' => $data['position'] ?? $data['code'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$statusId = (int) $db->lastInsertId();
|
||||||
|
$status = $this->findOrFail($statusId);
|
||||||
|
|
||||||
|
Response::success($status, 'Statut créé', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /statuses/{id}
|
||||||
|
*/
|
||||||
|
public function update(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$id = (int) $this->request->getParam('id');
|
||||||
|
$this->findOrFail($id);
|
||||||
|
|
||||||
|
$data = $this->validate([
|
||||||
|
'code' => 'int',
|
||||||
|
'name' => 'min:1|max:50',
|
||||||
|
'color' => 'max:7',
|
||||||
|
'position' => 'int',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
$params = ['id' => $id];
|
||||||
|
|
||||||
|
if (isset($data['code'])) {
|
||||||
|
$fields[] = 'code = :code';
|
||||||
|
$params['code'] = $data['code'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['name'])) {
|
||||||
|
$fields[] = 'name = :name';
|
||||||
|
$params['name'] = $data['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['color'])) {
|
||||||
|
$fields[] = 'color = :color';
|
||||||
|
$params['color'] = $data['color'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['position'])) {
|
||||||
|
$fields[] = 'position = :position';
|
||||||
|
$params['position'] = $data['position'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($fields)) {
|
||||||
|
$sql = 'UPDATE statuses SET ' . implode(', ', $fields) . ' WHERE id = :id';
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $this->findOrFail($id);
|
||||||
|
|
||||||
|
Response::success($status, 'Statut mis à jour');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /statuses/{id}
|
||||||
|
*/
|
||||||
|
public function destroy(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$id = (int) $this->request->getParam('id');
|
||||||
|
$status = $this->findOrFail($id);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Vérifier qu'aucune tâche n'utilise ce statut
|
||||||
|
$stmt = $db->prepare('SELECT COUNT(*) as count FROM tasks WHERE status_id = :id');
|
||||||
|
$stmt->execute(['id' => $id]);
|
||||||
|
$count = (int) $stmt->fetch()['count'];
|
||||||
|
|
||||||
|
if ($count > 0) {
|
||||||
|
Response::error("Impossible de supprimer : {$count} tâche(s) utilisent ce statut", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare('DELETE FROM statuses WHERE id = :id');
|
||||||
|
$stmt->execute(['id' => $id]);
|
||||||
|
|
||||||
|
Response::success(null, 'Statut supprimé');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouver un statut ou retourner 404
|
||||||
|
*/
|
||||||
|
private function findOrFail(int $id): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT * FROM statuses
|
||||||
|
WHERE id = :id AND user_id = :user_id
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'id' => $id,
|
||||||
|
'user_id' => $this->getUserId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$status = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$status) {
|
||||||
|
Response::notFound('Statut non trouvé');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifier qu'un projet appartient à l'utilisateur
|
||||||
|
*/
|
||||||
|
private function verifyProject(int $projectId): void
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('SELECT id FROM projects WHERE id = :id AND user_id = :user_id');
|
||||||
|
$stmt->execute(['id' => $projectId, 'user_id' => $this->getUserId()]);
|
||||||
|
|
||||||
|
if (!$stmt->fetch()) {
|
||||||
|
Response::error('Projet invalide', 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
235
clients/prokov/api/controllers/TagController.php
Normal file
235
clients/prokov/api/controllers/TagController.php
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Contrôleur des tags
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class TagController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /tags
|
||||||
|
*/
|
||||||
|
public function index(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT t.*,
|
||||||
|
(SELECT COUNT(*) FROM project_tags pt WHERE pt.tag_id = t.id) as project_count,
|
||||||
|
(SELECT COUNT(*) FROM task_tags tt WHERE tt.tag_id = t.id) as task_count
|
||||||
|
FROM tags t
|
||||||
|
WHERE t.user_id = :user_id
|
||||||
|
ORDER BY t.name ASC
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute(['user_id' => $this->getUserId()]);
|
||||||
|
$tags = $stmt->fetchAll();
|
||||||
|
|
||||||
|
Response::success($tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /tags/{id}
|
||||||
|
*/
|
||||||
|
public function show(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$id = (int) $this->request->getParam('id');
|
||||||
|
$tag = $this->findOrFail($id);
|
||||||
|
|
||||||
|
// Récupérer les projets associés
|
||||||
|
$tag['projects'] = $this->getTagProjects($id);
|
||||||
|
|
||||||
|
// Récupérer les tâches associées
|
||||||
|
$tag['tasks'] = $this->getTagTasks($id);
|
||||||
|
|
||||||
|
Response::success($tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /tags
|
||||||
|
*/
|
||||||
|
public function store(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$data = $this->validate([
|
||||||
|
'name' => 'required|min:1|max:50',
|
||||||
|
'color' => 'max:7',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Vérifier unicité du nom pour cet utilisateur
|
||||||
|
$stmt = $db->prepare('SELECT id FROM tags WHERE user_id = :user_id AND name = :name');
|
||||||
|
$stmt->execute(['user_id' => $this->getUserId(), 'name' => $data['name']]);
|
||||||
|
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
Response::error('Ce tag existe déjà', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
INSERT INTO tags (user_id, name, color)
|
||||||
|
VALUES (:user_id, :name, :color)
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'user_id' => $this->getUserId(),
|
||||||
|
'name' => $data['name'],
|
||||||
|
'color' => $data['color'] ?? '#3B82F6',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tagId = (int) $db->lastInsertId();
|
||||||
|
$tag = $this->findOrFail($tagId);
|
||||||
|
|
||||||
|
Response::success($tag, 'Tag créé', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /tags/{id}
|
||||||
|
*/
|
||||||
|
public function update(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$id = (int) $this->request->getParam('id');
|
||||||
|
$this->findOrFail($id);
|
||||||
|
|
||||||
|
$data = $this->validate([
|
||||||
|
'name' => 'min:1|max:50',
|
||||||
|
'color' => 'max:7',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Vérifier unicité du nom si modifié
|
||||||
|
if (!empty($data['name'])) {
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT id FROM tags
|
||||||
|
WHERE user_id = :user_id AND name = :name AND id != :id
|
||||||
|
');
|
||||||
|
$stmt->execute([
|
||||||
|
'user_id' => $this->getUserId(),
|
||||||
|
'name' => $data['name'],
|
||||||
|
'id' => $id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
Response::error('Ce tag existe déjà', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
$params = ['id' => $id];
|
||||||
|
|
||||||
|
if (isset($data['name'])) {
|
||||||
|
$fields[] = 'name = :name';
|
||||||
|
$params['name'] = $data['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['color'])) {
|
||||||
|
$fields[] = 'color = :color';
|
||||||
|
$params['color'] = $data['color'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($fields)) {
|
||||||
|
$sql = 'UPDATE tags SET ' . implode(', ', $fields) . ' WHERE id = :id';
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tag = $this->findOrFail($id);
|
||||||
|
|
||||||
|
Response::success($tag, 'Tag mis à jour');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /tags/{id}
|
||||||
|
*/
|
||||||
|
public function destroy(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$id = (int) $this->request->getParam('id');
|
||||||
|
$this->findOrFail($id);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
// Les associations seront supprimées en cascade (FK)
|
||||||
|
$stmt = $db->prepare('DELETE FROM tags WHERE id = :id');
|
||||||
|
$stmt->execute(['id' => $id]);
|
||||||
|
|
||||||
|
Response::success(null, 'Tag supprimé');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouver un tag ou retourner 404
|
||||||
|
*/
|
||||||
|
private function findOrFail(int $id): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT * FROM tags
|
||||||
|
WHERE id = :id AND user_id = :user_id
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'id' => $id,
|
||||||
|
'user_id' => $this->getUserId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tag = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$tag) {
|
||||||
|
Response::notFound('Tag non trouvé');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupérer les projets associés à un tag
|
||||||
|
*/
|
||||||
|
private function getTagProjects(int $tagId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT p.id, p.name
|
||||||
|
FROM projects p
|
||||||
|
JOIN project_tags pt ON p.id = pt.project_id
|
||||||
|
WHERE pt.tag_id = :tag_id
|
||||||
|
ORDER BY p.name ASC
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute(['tag_id' => $tagId]);
|
||||||
|
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupérer les tâches associées à un tag
|
||||||
|
*/
|
||||||
|
private function getTagTasks(int $tagId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT t.id, t.title, t.status_id, s.name as status_name
|
||||||
|
FROM tasks t
|
||||||
|
JOIN task_tags tt ON t.id = tt.task_id
|
||||||
|
LEFT JOIN statuses s ON t.status_id = s.id
|
||||||
|
WHERE tt.tag_id = :tag_id
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute(['tag_id' => $tagId]);
|
||||||
|
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
399
clients/prokov/api/controllers/TaskController.php
Normal file
399
clients/prokov/api/controllers/TaskController.php
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Contrôleur des tâches
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class TaskController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /tasks
|
||||||
|
* Liste les tâches avec filtres optionnels
|
||||||
|
* ?project_id=X - filtrer par projet
|
||||||
|
* ?status_id=X - filtrer par statut
|
||||||
|
* ?tag_id=X - filtrer par tag
|
||||||
|
* ?date_start=YYYY-MM-DD - tâches commençant après
|
||||||
|
* ?date_end=YYYY-MM-DD - tâches finissant avant
|
||||||
|
*/
|
||||||
|
public function index(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$where = ['t.user_id = :user_id'];
|
||||||
|
$params = ['user_id' => $this->getUserId()];
|
||||||
|
|
||||||
|
// Filtre par projet
|
||||||
|
$projectId = $this->request->get('project_id');
|
||||||
|
if ($projectId !== null) {
|
||||||
|
$where[] = 't.project_id = :project_id';
|
||||||
|
$params['project_id'] = (int) $projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par statut
|
||||||
|
$statusId = $this->request->get('status_id');
|
||||||
|
if ($statusId !== null) {
|
||||||
|
$where[] = 't.status_id = :status_id';
|
||||||
|
$params['status_id'] = (int) $statusId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par date de début
|
||||||
|
$dateStart = $this->request->get('date_start');
|
||||||
|
if ($dateStart !== null) {
|
||||||
|
$where[] = 't.date_start >= :date_start';
|
||||||
|
$params['date_start'] = $dateStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre par date de fin
|
||||||
|
$dateEnd = $this->request->get('date_end');
|
||||||
|
if ($dateEnd !== null) {
|
||||||
|
$where[] = 't.date_end <= :date_end';
|
||||||
|
$params['date_end'] = $dateEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = '
|
||||||
|
SELECT t.*,
|
||||||
|
p.name as project_name,
|
||||||
|
s.name as status_name,
|
||||||
|
s.color as status_color,
|
||||||
|
GROUP_CONCAT(tg.id) as tag_ids,
|
||||||
|
GROUP_CONCAT(tg.name) as tag_names,
|
||||||
|
GROUP_CONCAT(tg.color) as tag_colors
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN projects p ON t.project_id = p.id
|
||||||
|
LEFT JOIN statuses s ON t.status_id = s.id
|
||||||
|
LEFT JOIN task_tags tt ON t.id = tt.task_id
|
||||||
|
LEFT JOIN tags tg ON tt.tag_id = tg.id
|
||||||
|
WHERE ' . implode(' AND ', $where) . '
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY t.position ASC, t.priority DESC, t.created_at DESC
|
||||||
|
';
|
||||||
|
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$tasks = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Filtre par tag (après GROUP BY)
|
||||||
|
$tagId = $this->request->get('tag_id');
|
||||||
|
|
||||||
|
// Parser les tags
|
||||||
|
foreach ($tasks as &$task) {
|
||||||
|
$task['tags'] = $this->parseTags($task);
|
||||||
|
unset($task['tag_ids'], $task['tag_names'], $task['tag_colors']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appliquer filtre tag si nécessaire
|
||||||
|
if ($tagId !== null) {
|
||||||
|
$tagId = (int) $tagId;
|
||||||
|
$tasks = array_filter($tasks, function ($task) use ($tagId) {
|
||||||
|
foreach ($task['tags'] as $tag) {
|
||||||
|
if ($tag['id'] === $tagId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
$tasks = array_values($tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::success($tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /tasks/{id}
|
||||||
|
*/
|
||||||
|
public function show(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$id = (int) $this->request->getParam('id');
|
||||||
|
$task = $this->findOrFail($id);
|
||||||
|
|
||||||
|
$task['tags'] = $this->getTaskTags($id);
|
||||||
|
|
||||||
|
Response::success($task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /tasks
|
||||||
|
*/
|
||||||
|
public function store(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$data = $this->validate([
|
||||||
|
'project_id' => 'required|int',
|
||||||
|
'status_id' => 'required|int',
|
||||||
|
'title' => 'required|min:1|max:255',
|
||||||
|
'description' => 'max:65535',
|
||||||
|
'priority' => 'int',
|
||||||
|
'date_start' => 'max:10',
|
||||||
|
'date_end' => 'max:10',
|
||||||
|
'time_estimated' => 'int',
|
||||||
|
'time_spent' => 'int',
|
||||||
|
'billing' => 'numeric',
|
||||||
|
'position' => 'int',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Vérifier que le projet appartient à l'utilisateur
|
||||||
|
$this->verifyProject((int) $data['project_id']);
|
||||||
|
|
||||||
|
// Vérifier que le statut appartient à l'utilisateur
|
||||||
|
$this->verifyStatus((int) $data['status_id']);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
INSERT INTO tasks (user_id, project_id, status_id, title, description, priority,
|
||||||
|
date_start, date_end, time_estimated, time_spent, billing, position)
|
||||||
|
VALUES (:user_id, :project_id, :status_id, :title, :description, :priority,
|
||||||
|
:date_start, :date_end, :time_estimated, :time_spent, :billing, :position)
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'user_id' => $this->getUserId(),
|
||||||
|
'project_id' => $data['project_id'],
|
||||||
|
'status_id' => $data['status_id'],
|
||||||
|
'title' => $data['title'],
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'priority' => $data['priority'] ?? 5,
|
||||||
|
'date_start' => $data['date_start'] ?: null,
|
||||||
|
'date_end' => $data['date_end'] ?: null,
|
||||||
|
'time_estimated' => $data['time_estimated'] ?? 0,
|
||||||
|
'time_spent' => $data['time_spent'] ?? 0,
|
||||||
|
'billing' => $data['billing'] ?? 0,
|
||||||
|
'position' => $data['position'] ?? 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$taskId = (int) $db->lastInsertId();
|
||||||
|
|
||||||
|
// Gérer les tags si fournis
|
||||||
|
$tags = $this->request->get('tags');
|
||||||
|
if (is_array($tags)) {
|
||||||
|
$this->syncTags($taskId, $tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
$task = $this->findOrFail($taskId);
|
||||||
|
$task['tags'] = $this->getTaskTags($taskId);
|
||||||
|
|
||||||
|
Response::success($task, 'Tâche créée', 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /tasks/{id}
|
||||||
|
*/
|
||||||
|
public function update(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$id = (int) $this->request->getParam('id');
|
||||||
|
$this->findOrFail($id);
|
||||||
|
|
||||||
|
$data = $this->validate([
|
||||||
|
'project_id' => 'int',
|
||||||
|
'status_id' => 'int',
|
||||||
|
'title' => 'min:1|max:255',
|
||||||
|
'description' => 'max:65535',
|
||||||
|
'priority' => 'int',
|
||||||
|
'date_start' => 'max:10',
|
||||||
|
'date_end' => 'max:10',
|
||||||
|
'time_estimated' => 'int',
|
||||||
|
'time_spent' => 'int',
|
||||||
|
'billing' => 'numeric',
|
||||||
|
'position' => 'int',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($data['project_id'])) {
|
||||||
|
$this->verifyProject((int) $data['project_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($data['status_id'])) {
|
||||||
|
$this->verifyStatus((int) $data['status_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
$params = ['id' => $id];
|
||||||
|
|
||||||
|
$allowedFields = [
|
||||||
|
'project_id', 'status_id', 'title', 'description', 'priority',
|
||||||
|
'date_start', 'date_end', 'time_estimated', 'time_spent', 'billing', 'position'
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($allowedFields as $field) {
|
||||||
|
if (array_key_exists($field, $data)) {
|
||||||
|
$fields[] = "{$field} = :{$field}";
|
||||||
|
$value = $data[$field];
|
||||||
|
// Convertir les chaînes vides en null pour les dates
|
||||||
|
if (in_array($field, ['date_start', 'date_end']) && $value === '') {
|
||||||
|
$value = null;
|
||||||
|
}
|
||||||
|
$params[$field] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($fields)) {
|
||||||
|
$sql = 'UPDATE tasks SET ' . implode(', ', $fields) . ' WHERE id = :id';
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gérer les tags si fournis
|
||||||
|
$tags = $this->request->get('tags');
|
||||||
|
if (is_array($tags)) {
|
||||||
|
$this->syncTags($id, $tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
$task = $this->findOrFail($id);
|
||||||
|
$task['tags'] = $this->getTaskTags($id);
|
||||||
|
|
||||||
|
Response::success($task, 'Tâche mise à jour');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /tasks/{id}
|
||||||
|
*/
|
||||||
|
public function destroy(): void
|
||||||
|
{
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$id = (int) $this->request->getParam('id');
|
||||||
|
$this->findOrFail($id);
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('DELETE FROM tasks WHERE id = :id');
|
||||||
|
$stmt->execute(['id' => $id]);
|
||||||
|
|
||||||
|
Response::success(null, 'Tâche supprimée');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trouver une tâche ou retourner 404
|
||||||
|
*/
|
||||||
|
private function findOrFail(int $id): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT t.*, p.name as project_name, s.name as status_name, s.color as status_color
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN projects p ON t.project_id = p.id
|
||||||
|
LEFT JOIN statuses s ON t.status_id = s.id
|
||||||
|
WHERE t.id = :id AND t.user_id = :user_id
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'id' => $id,
|
||||||
|
'user_id' => $this->getUserId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$task = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$task) {
|
||||||
|
Response::notFound('Tâche non trouvée');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifier qu'un projet appartient à l'utilisateur
|
||||||
|
*/
|
||||||
|
private function verifyProject(int $projectId): void
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('SELECT id FROM projects WHERE id = :id AND user_id = :user_id');
|
||||||
|
$stmt->execute(['id' => $projectId, 'user_id' => $this->getUserId()]);
|
||||||
|
|
||||||
|
if (!$stmt->fetch()) {
|
||||||
|
Response::error('Projet invalide', 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifier qu'un statut appartient à l'utilisateur
|
||||||
|
*/
|
||||||
|
private function verifyStatus(int $statusId): void
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('SELECT id FROM statuses WHERE id = :id AND user_id = :user_id');
|
||||||
|
$stmt->execute(['id' => $statusId, 'user_id' => $this->getUserId()]);
|
||||||
|
|
||||||
|
if (!$stmt->fetch()) {
|
||||||
|
Response::error('Statut invalide', 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parser les tags depuis le GROUP_CONCAT
|
||||||
|
*/
|
||||||
|
private function parseTags(array $task): array
|
||||||
|
{
|
||||||
|
$tags = [];
|
||||||
|
if (!empty($task['tag_ids'])) {
|
||||||
|
$ids = explode(',', $task['tag_ids']);
|
||||||
|
$names = explode(',', $task['tag_names']);
|
||||||
|
$colors = explode(',', $task['tag_colors']);
|
||||||
|
foreach ($ids as $i => $tagId) {
|
||||||
|
$tags[] = [
|
||||||
|
'id' => (int) $tagId,
|
||||||
|
'name' => $names[$i] ?? '',
|
||||||
|
'color' => $colors[$i] ?? '#3B82F6',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupérer les tags d'une tâche
|
||||||
|
*/
|
||||||
|
private function getTaskTags(int $taskId): array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT t.id, t.name, t.color
|
||||||
|
FROM tags t
|
||||||
|
JOIN task_tags tt ON t.id = tt.tag_id
|
||||||
|
WHERE tt.task_id = :task_id
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute(['task_id' => $taskId]);
|
||||||
|
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchroniser les tags d'une tâche
|
||||||
|
*/
|
||||||
|
private function syncTags(int $taskId, array $tagIds): void
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('DELETE FROM task_tags WHERE task_id = :task_id');
|
||||||
|
$stmt->execute(['task_id' => $taskId]);
|
||||||
|
|
||||||
|
if (!empty($tagIds)) {
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
INSERT INTO task_tags (task_id, tag_id)
|
||||||
|
SELECT :task_id, id FROM tags
|
||||||
|
WHERE id = :tag_id AND user_id = :user_id
|
||||||
|
');
|
||||||
|
|
||||||
|
foreach ($tagIds as $tagId) {
|
||||||
|
$stmt->execute([
|
||||||
|
'task_id' => $taskId,
|
||||||
|
'tag_id' => (int) $tagId,
|
||||||
|
'user_id' => $this->getUserId(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
108
clients/prokov/api/core/Controller.php
Normal file
108
clients/prokov/api/core/Controller.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Contrôleur de base
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
protected Request $request;
|
||||||
|
protected ?array $user = null;
|
||||||
|
|
||||||
|
public function __construct(Request $request)
|
||||||
|
{
|
||||||
|
$this->request = $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requiert une authentification valide
|
||||||
|
*/
|
||||||
|
protected function requireAuth(): void
|
||||||
|
{
|
||||||
|
$sessionId = $this->request->getSessionId();
|
||||||
|
|
||||||
|
if (empty($sessionId)) {
|
||||||
|
Response::unauthorized('Session ID required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = Session::validate($sessionId);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
Response::unauthorized('Invalid or expired session');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->user = $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne l'ID de l'utilisateur authentifié
|
||||||
|
*/
|
||||||
|
protected function getUserId(): int
|
||||||
|
{
|
||||||
|
return $this->user['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide les champs requis dans le body
|
||||||
|
*/
|
||||||
|
protected function validate(array $rules): array
|
||||||
|
{
|
||||||
|
$body = $this->request->getBody();
|
||||||
|
$errors = [];
|
||||||
|
$data = [];
|
||||||
|
|
||||||
|
foreach ($rules as $field => $rule) {
|
||||||
|
$value = $body[$field] ?? null;
|
||||||
|
$ruleList = explode('|', $rule);
|
||||||
|
|
||||||
|
foreach ($ruleList as $r) {
|
||||||
|
if ($r === 'required' && ($value === null || $value === '')) {
|
||||||
|
$errors[$field] = "Le champ {$field} est requis";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($r === 'email' && $value !== null && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$errors[$field] = "Le champ {$field} doit être un email valide";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($r, 'min:')) {
|
||||||
|
$min = (int) substr($r, 4);
|
||||||
|
if ($value !== null && strlen($value) < $min) {
|
||||||
|
$errors[$field] = "Le champ {$field} doit contenir au moins {$min} caractères";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($r, 'max:')) {
|
||||||
|
$max = (int) substr($r, 4);
|
||||||
|
if ($value !== null && strlen($value) > $max) {
|
||||||
|
$errors[$field] = "Le champ {$field} doit contenir au maximum {$max} caractères";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($r === 'int' && $value !== null && !is_numeric($value)) {
|
||||||
|
$errors[$field] = "Le champ {$field} doit être un nombre entier";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($r === 'numeric' && $value !== null && !is_numeric($value)) {
|
||||||
|
$errors[$field] = "Le champ {$field} doit être un nombre";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($errors[$field])) {
|
||||||
|
$data[$field] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($errors)) {
|
||||||
|
Response::error('Validation failed', 422, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
112
clients/prokov/api/core/Request.php
Normal file
112
clients/prokov/api/core/Request.php
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Gestion de la requête entrante
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class Request
|
||||||
|
{
|
||||||
|
private string $method;
|
||||||
|
private string $uri;
|
||||||
|
private array $params = [];
|
||||||
|
private array $body = [];
|
||||||
|
private array $headers = [];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$this->uri = $this->parseUri();
|
||||||
|
$this->headers = $this->parseHeaders();
|
||||||
|
$this->body = $this->parseBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseUri(): string
|
||||||
|
{
|
||||||
|
$uri = $_SERVER['REQUEST_URI'] ?? '/';
|
||||||
|
|
||||||
|
// Retirer le query string
|
||||||
|
if (($pos = strpos($uri, '?')) !== false) {
|
||||||
|
$uri = substr($uri, 0, $pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retirer le préfixe /api si présent
|
||||||
|
$uri = preg_replace('#^/api#', '', $uri);
|
||||||
|
|
||||||
|
return '/' . trim($uri, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseHeaders(): array
|
||||||
|
{
|
||||||
|
$headers = [];
|
||||||
|
foreach ($_SERVER as $key => $value) {
|
||||||
|
if (str_starts_with($key, 'HTTP_')) {
|
||||||
|
$name = str_replace('_', '-', substr($key, 5));
|
||||||
|
$headers[$name] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseBody(): array
|
||||||
|
{
|
||||||
|
if (in_array($this->method, ['POST', 'PUT', 'PATCH'])) {
|
||||||
|
$input = file_get_contents('php://input');
|
||||||
|
if (!empty($input)) {
|
||||||
|
$decoded = json_decode($input, true);
|
||||||
|
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback sur $_POST ou array vide
|
||||||
|
return $_POST ?: [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMethod(): string
|
||||||
|
{
|
||||||
|
return $this->method;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUri(): string
|
||||||
|
{
|
||||||
|
return $this->uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeader(string $name): ?string
|
||||||
|
{
|
||||||
|
$name = strtoupper(str_replace('-', '_', $name));
|
||||||
|
return $this->headers[$name] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSessionId(): ?string
|
||||||
|
{
|
||||||
|
return $this->getHeader('X-SESSION-ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBody(): array
|
||||||
|
{
|
||||||
|
return $this->body;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $this->body[$key] ?? $_GET[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setParams(array $params): void
|
||||||
|
{
|
||||||
|
$this->params = $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getParam(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $this->params[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getParams(): array
|
||||||
|
{
|
||||||
|
return $this->params;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
clients/prokov/api/core/Response.php
Normal file
49
clients/prokov/api/core/Response.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Gestion des réponses JSON
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class Response
|
||||||
|
{
|
||||||
|
public static function json(mixed $data, int $code = 200): void
|
||||||
|
{
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function success(mixed $data = null, string $message = 'OK', int $code = 200): void
|
||||||
|
{
|
||||||
|
self::json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => $message,
|
||||||
|
'data' => $data,
|
||||||
|
], $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function error(string $message, int $code = 400, mixed $errors = null): void
|
||||||
|
{
|
||||||
|
$response = [
|
||||||
|
'success' => false,
|
||||||
|
'message' => $message,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($errors !== null) {
|
||||||
|
$response['errors'] = $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::json($response, $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function notFound(string $message = 'Resource not found'): void
|
||||||
|
{
|
||||||
|
self::error($message, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function unauthorized(string $message = 'Unauthorized'): void
|
||||||
|
{
|
||||||
|
self::error($message, 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
145
clients/prokov/api/core/Router.php
Normal file
145
clients/prokov/api/core/Router.php
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Routeur simple pour API REST
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class Router
|
||||||
|
{
|
||||||
|
private array $routes = [];
|
||||||
|
private Request $request;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->request = new Request();
|
||||||
|
$this->registerRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registerRoutes(): void
|
||||||
|
{
|
||||||
|
// Auth (routes publiques)
|
||||||
|
$this->post('/auth/register', 'AuthController@register');
|
||||||
|
$this->post('/auth/login', 'AuthController@login');
|
||||||
|
$this->post('/auth/logout', 'AuthController@logout');
|
||||||
|
$this->get('/auth/me', 'AuthController@me');
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
$this->get('/projects', 'ProjectController@index');
|
||||||
|
$this->get('/projects/{id}', 'ProjectController@show');
|
||||||
|
$this->post('/projects', 'ProjectController@store');
|
||||||
|
$this->put('/projects/{id}', 'ProjectController@update');
|
||||||
|
$this->delete('/projects/{id}', 'ProjectController@destroy');
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
$this->get('/tasks', 'TaskController@index');
|
||||||
|
$this->get('/tasks/{id}', 'TaskController@show');
|
||||||
|
$this->post('/tasks', 'TaskController@store');
|
||||||
|
$this->put('/tasks/{id}', 'TaskController@update');
|
||||||
|
$this->delete('/tasks/{id}', 'TaskController@destroy');
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
$this->get('/tags', 'TagController@index');
|
||||||
|
$this->get('/tags/{id}', 'TagController@show');
|
||||||
|
$this->post('/tags', 'TagController@store');
|
||||||
|
$this->put('/tags/{id}', 'TagController@update');
|
||||||
|
$this->delete('/tags/{id}', 'TagController@destroy');
|
||||||
|
|
||||||
|
// Statuses
|
||||||
|
$this->get('/statuses', 'StatusController@index');
|
||||||
|
$this->get('/statuses/{id}', 'StatusController@show');
|
||||||
|
$this->post('/statuses', 'StatusController@store');
|
||||||
|
$this->put('/statuses/{id}', 'StatusController@update');
|
||||||
|
$this->delete('/statuses/{id}', 'StatusController@destroy');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addRoute(string $method, string $path, string $handler): void
|
||||||
|
{
|
||||||
|
$this->routes[] = [
|
||||||
|
'method' => $method,
|
||||||
|
'path' => $path,
|
||||||
|
'handler' => $handler,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $path, string $handler): void
|
||||||
|
{
|
||||||
|
$this->addRoute('GET', $path, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function post(string $path, string $handler): void
|
||||||
|
{
|
||||||
|
$this->addRoute('POST', $path, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $path, string $handler): void
|
||||||
|
{
|
||||||
|
$this->addRoute('PUT', $path, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $path, string $handler): void
|
||||||
|
{
|
||||||
|
$this->addRoute('DELETE', $path, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatch(): void
|
||||||
|
{
|
||||||
|
$method = $this->request->getMethod();
|
||||||
|
$uri = $this->request->getUri();
|
||||||
|
|
||||||
|
foreach ($this->routes as $route) {
|
||||||
|
if ($route['method'] !== $method) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$params = $this->matchRoute($route['path'], $uri);
|
||||||
|
if ($params !== false) {
|
||||||
|
$this->request->setParams($params);
|
||||||
|
$this->callHandler($route['handler']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::notFound('Route not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function matchRoute(string $routePath, string $uri): array|false
|
||||||
|
{
|
||||||
|
// Convertir /projects/{id} en regex /projects/([^/]+)
|
||||||
|
$pattern = preg_replace('#\{(\w+)\}#', '([^/]+)', $routePath);
|
||||||
|
$pattern = '#^' . $pattern . '$#';
|
||||||
|
|
||||||
|
if (preg_match($pattern, $uri, $matches)) {
|
||||||
|
array_shift($matches); // Retirer le match complet
|
||||||
|
|
||||||
|
// Extraire les noms des paramètres
|
||||||
|
preg_match_all('#\{(\w+)\}#', $routePath, $paramNames);
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
foreach ($paramNames[1] as $index => $name) {
|
||||||
|
$params[$name] = $matches[$index] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function callHandler(string $handler): void
|
||||||
|
{
|
||||||
|
[$controllerName, $methodName] = explode('@', $handler);
|
||||||
|
|
||||||
|
if (!class_exists($controllerName)) {
|
||||||
|
Response::error("Controller {$controllerName} not found", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$controller = new $controllerName($this->request);
|
||||||
|
|
||||||
|
if (!method_exists($controller, $methodName)) {
|
||||||
|
Response::error("Method {$methodName} not found", 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$controller->$methodName();
|
||||||
|
}
|
||||||
|
}
|
||||||
162
clients/prokov/api/core/Session.php
Normal file
162
clients/prokov/api/core/Session.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Gestion des sessions en base de données
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class Session
|
||||||
|
{
|
||||||
|
private static ?array $currentSession = null;
|
||||||
|
private static ?array $currentUser = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupérer l'IP réelle du client (derrière proxy)
|
||||||
|
*/
|
||||||
|
public static function getClientIp(): ?string
|
||||||
|
{
|
||||||
|
// Headers transmis par le proxy nginx
|
||||||
|
$headers = [
|
||||||
|
'HTTP_X_REAL_IP',
|
||||||
|
'HTTP_X_FORWARDED_FOR',
|
||||||
|
'HTTP_CLIENT_IP',
|
||||||
|
'REMOTE_ADDR',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($headers as $header) {
|
||||||
|
if (!empty($_SERVER[$header])) {
|
||||||
|
// X-Forwarded-For peut contenir plusieurs IPs (client, proxy1, proxy2...)
|
||||||
|
$ips = explode(',', $_SERVER[$header]);
|
||||||
|
$ip = trim($ips[0]);
|
||||||
|
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||||
|
return $ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Créer une nouvelle session pour un utilisateur
|
||||||
|
*/
|
||||||
|
public static function create(int $userId, ?string $ipAddress = null, ?string $userAgent = null): string
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$sessionId = bin2hex(random_bytes(64)); // 128 caractères
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', time() + SESSION_LIFETIME);
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at)
|
||||||
|
VALUES (:id, :user_id, :ip_address, :user_agent, :expires_at)
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'id' => $sessionId,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'ip_address' => $ipAddress ?? self::getClientIp(),
|
||||||
|
'user_agent' => $userAgent ?? $_SERVER['HTTP_USER_AGENT'] ?? null,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valider une session et retourner l'utilisateur
|
||||||
|
*/
|
||||||
|
public static function validate(string $sessionId): ?array
|
||||||
|
{
|
||||||
|
if (self::$currentSession !== null && self::$currentSession['id'] === $sessionId) {
|
||||||
|
return self::$currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT s.*, u.id as user_id, u.email, u.name
|
||||||
|
FROM sessions s
|
||||||
|
JOIN users u ON s.user_id = u.id
|
||||||
|
WHERE s.id = :id AND s.expires_at > NOW()
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute(['id' => $sessionId]);
|
||||||
|
$result = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::$currentSession = [
|
||||||
|
'id' => $result['id'],
|
||||||
|
'user_id' => $result['user_id'],
|
||||||
|
'expires_at' => $result['expires_at'],
|
||||||
|
];
|
||||||
|
|
||||||
|
self::$currentUser = [
|
||||||
|
'id' => $result['user_id'],
|
||||||
|
'email' => $result['email'],
|
||||||
|
'name' => $result['name'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return self::$currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Détruire une session
|
||||||
|
*/
|
||||||
|
public static function destroy(string $sessionId): bool
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('DELETE FROM sessions WHERE id = :id');
|
||||||
|
$stmt->execute(['id' => $sessionId]);
|
||||||
|
|
||||||
|
self::$currentSession = null;
|
||||||
|
self::$currentUser = null;
|
||||||
|
|
||||||
|
return $stmt->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nettoyer les sessions expirées
|
||||||
|
*/
|
||||||
|
public static function cleanup(): int
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('DELETE FROM sessions WHERE expires_at < NOW()');
|
||||||
|
$stmt->execute();
|
||||||
|
|
||||||
|
return $stmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prolonger une session
|
||||||
|
*/
|
||||||
|
public static function extend(string $sessionId): bool
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', time() + SESSION_LIFETIME);
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
UPDATE sessions SET expires_at = :expires_at WHERE id = :id
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
'id' => $sessionId,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $stmt->rowCount() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtenir l'utilisateur courant (depuis le cache)
|
||||||
|
*/
|
||||||
|
public static function getCurrentUser(): ?array
|
||||||
|
{
|
||||||
|
return self::$currentUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
147
clients/prokov/api/deploy-api.sh
Executable file
147
clients/prokov/api/deploy-api.sh
Executable file
@@ -0,0 +1,147 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script de déploiement pour PROKOV API
|
||||||
|
# Version: 1.0 (12 décembre 2025)
|
||||||
|
# Auteur: Pierre (avec l'aide de Claude)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ENV=DEV
|
||||||
|
JUMP_USER="root"
|
||||||
|
JUMP_HOST="195.154.80.116"
|
||||||
|
JUMP_PORT="22"
|
||||||
|
JUMP_KEY="/home/pierre/.ssh/id_rsa_mbpi"
|
||||||
|
INCUS_PROJECT=default
|
||||||
|
INCUS_CONTAINER=dva-front
|
||||||
|
|
||||||
|
# Paramètres du container Incus
|
||||||
|
CONTAINER_USER=root
|
||||||
|
CONTAINER_IP="13.23.33.42"
|
||||||
|
|
||||||
|
# Paramètres de déploiement
|
||||||
|
FINAL_PATH="/var/www/prokov/api"
|
||||||
|
FINAL_OWNER="nginx"
|
||||||
|
FINAL_GROUP="nginx"
|
||||||
|
FINAL_OWNER_LOGS="nobody"
|
||||||
|
|
||||||
|
# Couleurs pour les messages
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Fonction pour afficher les messages d'étape
|
||||||
|
echo_step() {
|
||||||
|
echo -e "${GREEN}==>${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fonction pour afficher les informations
|
||||||
|
echo_info() {
|
||||||
|
echo -e "${BLUE}Info:${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fonction pour afficher les avertissements
|
||||||
|
echo_warning() {
|
||||||
|
echo -e "${YELLOW}Warning:${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fonction pour afficher les erreurs
|
||||||
|
echo_error() {
|
||||||
|
echo -e "${RED}Error:${NC} $1"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vérification de l'environnement
|
||||||
|
echo_step "Verifying environment..."
|
||||||
|
echo_info "Deploying PROKOV API to $ENV environment"
|
||||||
|
echo_info "Container: $INCUS_CONTAINER (IP: $CONTAINER_IP)"
|
||||||
|
echo_info "Target path: $FINAL_PATH"
|
||||||
|
|
||||||
|
# Vérification des fichiers requis
|
||||||
|
if [ ! -f "public/index.php" ]; then
|
||||||
|
echo_error "public/index.php missing - are you in the api directory?"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "core" ] || [ ! -d "controllers" ]; then
|
||||||
|
echo_error "API structure incomplete (core/ or controllers/ missing)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Étape 0: Définir le nom de l'archive
|
||||||
|
ARCHIVE_NAME="prokov-api-${ENV}-$(date +%s).tar.gz"
|
||||||
|
ARCHIVE_PATH="/tmp/${ARCHIVE_NAME}"
|
||||||
|
echo_info "Archive name will be: $ARCHIVE_NAME"
|
||||||
|
|
||||||
|
# Étape 1: Créer une archive du projet
|
||||||
|
echo_step "Creating project archive..."
|
||||||
|
tar --exclude='.git' \
|
||||||
|
--exclude='.gitignore' \
|
||||||
|
--exclude='.vscode' \
|
||||||
|
--exclude='logs' \
|
||||||
|
--exclude='*.template' \
|
||||||
|
--exclude='*.sh' \
|
||||||
|
--exclude='.env' \
|
||||||
|
--exclude='*.log' \
|
||||||
|
--exclude='.DS_Store' \
|
||||||
|
--exclude='README.md' \
|
||||||
|
--exclude="*.tar.gz" \
|
||||||
|
--no-xattrs \
|
||||||
|
-czf "${ARCHIVE_PATH}" . || echo_error "Failed to create archive"
|
||||||
|
|
||||||
|
# Vérifier la taille de l'archive
|
||||||
|
ARCHIVE_SIZE=$(du -h "${ARCHIVE_PATH}" | cut -f1)
|
||||||
|
|
||||||
|
SSH_JUMP_CMD="ssh -i ${JUMP_KEY} -p ${JUMP_PORT} ${JUMP_USER}@${JUMP_HOST}"
|
||||||
|
|
||||||
|
# Étape 2: Copier l'archive vers le serveur de saut (IN3)
|
||||||
|
echo_step "Copying archive to jump server (IN3)..."
|
||||||
|
echo_info "Archive size: $ARCHIVE_SIZE"
|
||||||
|
scp -i "${JUMP_KEY}" -P "${JUMP_PORT}" "${ARCHIVE_PATH}" "${JUMP_USER}@${JUMP_HOST}:/tmp/${ARCHIVE_NAME}" || echo_error "Failed to copy archive to jump server"
|
||||||
|
|
||||||
|
# Étape 3: Exécuter les commandes sur IN3 pour déployer dans le container Incus dva-front
|
||||||
|
echo_step "Deploying to Incus container ($INCUS_CONTAINER)..."
|
||||||
|
$SSH_JUMP_CMD "
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo '✅ Passage au projet Incus...'
|
||||||
|
incus project switch ${INCUS_PROJECT} || exit 1
|
||||||
|
|
||||||
|
echo '📦 Poussée de l archive dans le conteneur...'
|
||||||
|
incus file push /tmp/${ARCHIVE_NAME} ${INCUS_CONTAINER}/tmp/${ARCHIVE_NAME} || exit 1
|
||||||
|
|
||||||
|
echo '📁 Préparation du dossier final...'
|
||||||
|
incus exec ${INCUS_CONTAINER} -- mkdir -p ${FINAL_PATH} || exit 1
|
||||||
|
incus exec ${INCUS_CONTAINER} -- rm -rf ${FINAL_PATH}/* || exit 1
|
||||||
|
incus exec ${INCUS_CONTAINER} -- tar -xzf /tmp/${ARCHIVE_NAME} -C ${FINAL_PATH}/ || exit 1
|
||||||
|
|
||||||
|
echo '🔧 Réglage des permissions...'
|
||||||
|
incus exec ${INCUS_CONTAINER} -- mkdir -p ${FINAL_PATH}/logs || exit 1
|
||||||
|
incus exec ${INCUS_CONTAINER} -- chown -R ${FINAL_OWNER}:${FINAL_GROUP} ${FINAL_PATH} || exit 1
|
||||||
|
incus exec ${INCUS_CONTAINER} -- find ${FINAL_PATH} -type d -exec chmod 755 {} \; || exit 1
|
||||||
|
incus exec ${INCUS_CONTAINER} -- find ${FINAL_PATH} -type f -exec chmod 644 {} \; || exit 1
|
||||||
|
|
||||||
|
# Permissions spéciales pour le dossier logs
|
||||||
|
incus exec ${INCUS_CONTAINER} -- chown -R ${FINAL_OWNER}:${FINAL_OWNER_LOGS} ${FINAL_PATH}/logs || exit 1
|
||||||
|
incus exec ${INCUS_CONTAINER} -- chmod -R 775 ${FINAL_PATH}/logs || exit 1
|
||||||
|
|
||||||
|
echo '🧹 Nettoyage...'
|
||||||
|
incus exec ${INCUS_CONTAINER} -- rm -f /tmp/${ARCHIVE_NAME} || exit 1
|
||||||
|
rm -f /tmp/${ARCHIVE_NAME} || exit 1
|
||||||
|
"
|
||||||
|
|
||||||
|
# Nettoyage local
|
||||||
|
rm -f "${ARCHIVE_PATH}"
|
||||||
|
|
||||||
|
# Résumé final
|
||||||
|
echo_step "Deployment completed successfully."
|
||||||
|
echo ""
|
||||||
|
echo_info "PROKOV API deployed to $ENV environment"
|
||||||
|
echo_info " Host: IN3 ($JUMP_HOST)"
|
||||||
|
echo_info " Container: $INCUS_CONTAINER ($CONTAINER_IP)"
|
||||||
|
echo_info " Path: $FINAL_PATH"
|
||||||
|
echo_info " Deployment time: $(date)"
|
||||||
|
echo ""
|
||||||
|
echo_info "API should be accessible at: https://prokov.unikoffice.com/api/"
|
||||||
|
|
||||||
|
# Journaliser le déploiement
|
||||||
|
echo "$(date '+%Y-%m-%d %H:%M:%S') - PROKOV API deployed to ${ENV} (${INCUS_CONTAINER}:${FINAL_PATH})" >> ~/.prokov_deploy_history
|
||||||
74
clients/prokov/api/models/User.php
Normal file
74
clients/prokov/api/models/User.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Modèle User
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class User
|
||||||
|
{
|
||||||
|
public static function findById(int $id): ?array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT id, email, name, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
WHERE id = :id
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute(['id' => $id]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
return $user ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findByEmail(string $email): ?array
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$stmt = $db->prepare('
|
||||||
|
SELECT id, email, name, password, created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
WHERE email = :email
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute(['email' => $email]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
return $user ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function update(int $id, array $data): bool
|
||||||
|
{
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
$params = ['id' => $id];
|
||||||
|
|
||||||
|
if (isset($data['name'])) {
|
||||||
|
$fields[] = 'name = :name';
|
||||||
|
$params['name'] = $data['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['email'])) {
|
||||||
|
$fields[] = 'email = :email';
|
||||||
|
$params['email'] = $data['email'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['password'])) {
|
||||||
|
$fields[] = 'password = :password';
|
||||||
|
$params['password'] = password_hash($data['password'], PASSWORD_DEFAULT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($fields)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = 'UPDATE users SET ' . implode(', ', $fields) . ' WHERE id = :id';
|
||||||
|
$stmt = $db->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
return $stmt->rowCount() > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
47
clients/prokov/api/public/index.php
Normal file
47
clients/prokov/api/public/index.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* PROKOV API - Point d'entrée
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Chemin racine de l'API (dossier parent)
|
||||||
|
define('API_ROOT', dirname(__DIR__));
|
||||||
|
|
||||||
|
// Headers CORS et JSON
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Session-Id');
|
||||||
|
header('Access-Control-Allow-Credentials: true');
|
||||||
|
|
||||||
|
// Preflight OPTIONS
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(204);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autoload simple
|
||||||
|
spl_autoload_register(function (string $class): void {
|
||||||
|
$paths = [
|
||||||
|
'config/',
|
||||||
|
'core/',
|
||||||
|
'controllers/',
|
||||||
|
'models/',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
$file = API_ROOT . '/' . $path . $class . '.php';
|
||||||
|
if (file_exists($file)) {
|
||||||
|
require_once $file;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chargement config
|
||||||
|
require_once API_ROOT . '/config/config.php';
|
||||||
|
|
||||||
|
// Initialisation
|
||||||
|
$router = new Router();
|
||||||
|
$router->dispatch();
|
||||||
403
cmd/sogoms/db/main.go
Executable file
403
cmd/sogoms/db/main.go
Executable file
@@ -0,0 +1,403 @@
|
|||||||
|
// sogoms-db : Microservice d'accès à MariaDB.
|
||||||
|
// Chaque application cliente a sa propre base de données.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
|
||||||
|
"sogoms.com/internal/config"
|
||||||
|
"sogoms.com/internal/protocol"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
socketPath = flag.String("socket", "/run/sogoms-db.1.sock", "Unix socket path")
|
||||||
|
configDir = flag.String("config", "/config", "Configuration directory")
|
||||||
|
)
|
||||||
|
|
||||||
|
// DBPool gère les connexions DB par application.
|
||||||
|
type DBPool struct {
|
||||||
|
registry *config.Registry
|
||||||
|
pools map[string]*sql.DB
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDBPool(registry *config.Registry) *DBPool {
|
||||||
|
return &DBPool{
|
||||||
|
registry: registry,
|
||||||
|
pools: make(map[string]*sql.DB),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDB retourne une connexion DB pour l'application spécifiée.
|
||||||
|
func (p *DBPool) GetDB(appID string) (*sql.DB, error) {
|
||||||
|
p.mu.RLock()
|
||||||
|
db, ok := p.pools[appID]
|
||||||
|
p.mu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer une nouvelle connexion
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
// Double-check après le lock
|
||||||
|
if db, ok := p.pools[appID]; ok {
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, ok := p.registry.GetByApp(appID)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown app: %s", appID)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("mysql", cfg.Database.DSN())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration du pool
|
||||||
|
db.SetMaxOpenConns(10)
|
||||||
|
db.SetMaxIdleConns(5)
|
||||||
|
|
||||||
|
// Test de connexion
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("ping db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.pools[appID] = db
|
||||||
|
log.Printf("[db] connected to database for app: %s", appID)
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close ferme toutes les connexions.
|
||||||
|
func (p *DBPool) Close() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for appID, db := range p.pools {
|
||||||
|
db.Close()
|
||||||
|
log.Printf("[db] closed connection for app: %s", appID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
log.SetFlags(log.Ltime | log.Lshortfile)
|
||||||
|
|
||||||
|
// Charger les configurations
|
||||||
|
registry := config.NewRegistry(*configDir)
|
||||||
|
if err := registry.Load(); err != nil {
|
||||||
|
log.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
log.Printf("[db] loaded apps: %v", registry.Apps())
|
||||||
|
|
||||||
|
// Pool de connexions DB
|
||||||
|
dbPool := NewDBPool(registry)
|
||||||
|
defer dbPool.Close()
|
||||||
|
|
||||||
|
// Handler des requêtes
|
||||||
|
handler := func(ctx context.Context, req *protocol.Request) *protocol.Response {
|
||||||
|
return handleRequest(ctx, req, dbPool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Démarrer le serveur
|
||||||
|
server := protocol.NewServer(*socketPath, handler)
|
||||||
|
if err := server.Start(); err != nil {
|
||||||
|
log.Fatalf("start server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[db] sogoms-db started on %s", *socketPath)
|
||||||
|
|
||||||
|
// Attendre signal d'arrêt
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
|
||||||
|
log.Printf("[db] shutting down...")
|
||||||
|
server.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleRequest(ctx context.Context, req *protocol.Request, dbPool *DBPool) *protocol.Response {
|
||||||
|
// L'app_id doit être fourni
|
||||||
|
appID, ok := req.Params["app_id"].(string)
|
||||||
|
if !ok || appID == "" {
|
||||||
|
return protocol.Failure(req.ID, "MISSING_APP_ID", "app_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := dbPool.GetDB(appID)
|
||||||
|
if err != nil {
|
||||||
|
return protocol.Failure(req.ID, "DB_ERROR", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Action {
|
||||||
|
case "query":
|
||||||
|
return handleQuery(req, db)
|
||||||
|
case "query_one":
|
||||||
|
return handleQueryOne(req, db)
|
||||||
|
case "insert":
|
||||||
|
return handleInsert(req, db)
|
||||||
|
case "update":
|
||||||
|
return handleUpdate(req, db)
|
||||||
|
case "delete":
|
||||||
|
return handleDelete(req, db)
|
||||||
|
case "health":
|
||||||
|
return handleHealth(req, db)
|
||||||
|
default:
|
||||||
|
return protocol.Failure(req.ID, "UNKNOWN_ACTION", "unknown action: "+req.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleQuery exécute un SELECT et retourne plusieurs lignes.
|
||||||
|
func handleQuery(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||||
|
query, args, err := extractQueryParams(req.Params)
|
||||||
|
if err != nil {
|
||||||
|
return protocol.Failure(req.ID, "INVALID_PARAMS", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return protocol.Failure(req.ID, "QUERY_ERROR", err.Error())
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
results, err := scanRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return protocol.Failure(req.ID, "SCAN_ERROR", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return protocol.Success(req.ID, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleQueryOne exécute un SELECT et retourne une seule ligne.
|
||||||
|
func handleQueryOne(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||||
|
query, args, err := extractQueryParams(req.Params)
|
||||||
|
if err != nil {
|
||||||
|
return protocol.Failure(req.ID, "INVALID_PARAMS", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return protocol.Failure(req.ID, "QUERY_ERROR", err.Error())
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
results, err := scanRows(rows)
|
||||||
|
if err != nil {
|
||||||
|
return protocol.Failure(req.ID, "SCAN_ERROR", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
return protocol.Failure(req.ID, "NOT_FOUND", "no rows found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return protocol.Success(req.ID, results[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInsert exécute un INSERT et retourne l'ID inséré.
|
||||||
|
func handleInsert(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||||
|
table, ok := req.Params["table"].(string)
|
||||||
|
if !ok {
|
||||||
|
return protocol.Failure(req.ID, "INVALID_PARAMS", "table is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := req.Params["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return protocol.Failure(req.ID, "INVALID_PARAMS", "data is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire la requête INSERT
|
||||||
|
columns := make([]string, 0, len(data))
|
||||||
|
placeholders := make([]string, 0, len(data))
|
||||||
|
values := make([]any, 0, len(data))
|
||||||
|
|
||||||
|
for col, val := range data {
|
||||||
|
columns = append(columns, col)
|
||||||
|
placeholders = append(placeholders, "?")
|
||||||
|
values = append(values, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
|
||||||
|
table,
|
||||||
|
strings.Join(columns, ", "),
|
||||||
|
strings.Join(placeholders, ", "))
|
||||||
|
|
||||||
|
result, err := db.Exec(query, values...)
|
||||||
|
if err != nil {
|
||||||
|
return protocol.Failure(req.ID, "INSERT_ERROR", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
insertID, _ := result.LastInsertId()
|
||||||
|
return protocol.Success(req.ID, map[string]any{
|
||||||
|
"insert_id": insertID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdate exécute un UPDATE et retourne le nombre de lignes affectées.
|
||||||
|
func handleUpdate(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||||
|
table, ok := req.Params["table"].(string)
|
||||||
|
if !ok {
|
||||||
|
return protocol.Failure(req.ID, "INVALID_PARAMS", "table is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, ok := req.Params["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return protocol.Failure(req.ID, "INVALID_PARAMS", "data is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
where, ok := req.Params["where"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return protocol.Failure(req.ID, "INVALID_PARAMS", "where is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire SET
|
||||||
|
setClauses := make([]string, 0, len(data))
|
||||||
|
values := make([]any, 0, len(data)+len(where))
|
||||||
|
|
||||||
|
for col, val := range data {
|
||||||
|
setClauses = append(setClauses, col+" = ?")
|
||||||
|
values = append(values, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire WHERE
|
||||||
|
whereClauses := make([]string, 0, len(where))
|
||||||
|
for col, val := range where {
|
||||||
|
whereClauses = append(whereClauses, col+" = ?")
|
||||||
|
values = append(values, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("UPDATE %s SET %s WHERE %s",
|
||||||
|
table,
|
||||||
|
strings.Join(setClauses, ", "),
|
||||||
|
strings.Join(whereClauses, " AND "))
|
||||||
|
|
||||||
|
result, err := db.Exec(query, values...)
|
||||||
|
if err != nil {
|
||||||
|
return protocol.Failure(req.ID, "UPDATE_ERROR", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
return protocol.Success(req.ID, map[string]any{
|
||||||
|
"affected_rows": affected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDelete exécute un DELETE et retourne le nombre de lignes affectées.
|
||||||
|
func handleDelete(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||||
|
table, ok := req.Params["table"].(string)
|
||||||
|
if !ok {
|
||||||
|
return protocol.Failure(req.ID, "INVALID_PARAMS", "table is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
where, ok := req.Params["where"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return protocol.Failure(req.ID, "INVALID_PARAMS", "where is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire WHERE
|
||||||
|
whereClauses := make([]string, 0, len(where))
|
||||||
|
values := make([]any, 0, len(where))
|
||||||
|
|
||||||
|
for col, val := range where {
|
||||||
|
whereClauses = append(whereClauses, col+" = ?")
|
||||||
|
values = append(values, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf("DELETE FROM %s WHERE %s",
|
||||||
|
table,
|
||||||
|
strings.Join(whereClauses, " AND "))
|
||||||
|
|
||||||
|
result, err := db.Exec(query, values...)
|
||||||
|
if err != nil {
|
||||||
|
return protocol.Failure(req.ID, "DELETE_ERROR", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
return protocol.Success(req.ID, map[string]any{
|
||||||
|
"affected_rows": affected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHealth vérifie la connexion à la DB.
|
||||||
|
func handleHealth(req *protocol.Request, db *sql.DB) *protocol.Response {
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return protocol.Failure(req.ID, "UNHEALTHY", err.Error())
|
||||||
|
}
|
||||||
|
return protocol.Success(req.ID, map[string]any{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractQueryParams extrait query et args des paramètres.
|
||||||
|
func extractQueryParams(params map[string]any) (string, []any, error) {
|
||||||
|
query, ok := params["query"].(string)
|
||||||
|
if !ok {
|
||||||
|
return "", nil, fmt.Errorf("query is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var args []any
|
||||||
|
if argsRaw, ok := params["args"]; ok {
|
||||||
|
switch v := argsRaw.(type) {
|
||||||
|
case []any:
|
||||||
|
args = v
|
||||||
|
default:
|
||||||
|
// Essayer de convertir via JSON
|
||||||
|
data, _ := json.Marshal(argsRaw)
|
||||||
|
json.Unmarshal(data, &args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return query, args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanRows convertit les résultats SQL en slice de maps.
|
||||||
|
func scanRows(rows *sql.Rows) ([]map[string]any, error) {
|
||||||
|
columns, err := rows.Columns()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []map[string]any
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
// Créer des pointeurs pour scanner
|
||||||
|
values := make([]any, len(columns))
|
||||||
|
valuePtrs := make([]any, len(columns))
|
||||||
|
for i := range values {
|
||||||
|
valuePtrs[i] = &values[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Scan(valuePtrs...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construire la map
|
||||||
|
row := make(map[string]any)
|
||||||
|
for i, col := range columns {
|
||||||
|
val := values[i]
|
||||||
|
// Convertir []byte en string
|
||||||
|
if b, ok := val.([]byte); ok {
|
||||||
|
row[col] = string(b)
|
||||||
|
} else {
|
||||||
|
row[col] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
if results == nil {
|
||||||
|
results = []map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, rows.Err()
|
||||||
|
}
|
||||||
4
cmd/sogoms/email/main.go
Executable file
4
cmd/sogoms/email/main.go
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
}
|
||||||
4
cmd/sogoms/pdf/main.go
Executable file
4
cmd/sogoms/pdf/main.go
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
}
|
||||||
4
cmd/sogoms/storage/main.go
Executable file
4
cmd/sogoms/storage/main.go
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
}
|
||||||
4
cmd/sogorch/main.go
Executable file
4
cmd/sogorch/main.go
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
}
|
||||||
129
config/routes/prokov.yaml
Normal file
129
config/routes/prokov.yaml
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Routes API Prokov
|
||||||
|
# Gestion de projets et tâches
|
||||||
|
|
||||||
|
app: prokov
|
||||||
|
version: "1.0"
|
||||||
|
base_path: /api
|
||||||
|
|
||||||
|
# Identification par hostname
|
||||||
|
hosts:
|
||||||
|
- prokov.unikoffice.com
|
||||||
|
- prokov.sogoms.com
|
||||||
|
|
||||||
|
# Base de données
|
||||||
|
database:
|
||||||
|
host: 13.23.33.4
|
||||||
|
port: 3306
|
||||||
|
user: prokov_user
|
||||||
|
password_file: /secrets/prokov_db_pass
|
||||||
|
name: prokov
|
||||||
|
|
||||||
|
# Authentification
|
||||||
|
auth:
|
||||||
|
jwt_secret_file: /secrets/prokov_jwt_secret
|
||||||
|
jwt_expiry: 24h
|
||||||
|
|
||||||
|
# Routes
|
||||||
|
routes:
|
||||||
|
# === AUTH ===
|
||||||
|
- path: /auth/register
|
||||||
|
method: POST
|
||||||
|
scenario: prokov/auth/register
|
||||||
|
auth: false
|
||||||
|
|
||||||
|
- path: /auth/login
|
||||||
|
method: POST
|
||||||
|
scenario: prokov/auth/login
|
||||||
|
auth: false
|
||||||
|
|
||||||
|
- path: /auth/logout
|
||||||
|
method: POST
|
||||||
|
scenario: prokov/auth/logout
|
||||||
|
|
||||||
|
- path: /auth/me
|
||||||
|
method: GET
|
||||||
|
scenario: prokov/auth/me
|
||||||
|
|
||||||
|
# === PROJECTS ===
|
||||||
|
- path: /projects
|
||||||
|
method: GET
|
||||||
|
scenario: prokov/projects/list
|
||||||
|
|
||||||
|
- path: /projects
|
||||||
|
method: POST
|
||||||
|
scenario: prokov/projects/create
|
||||||
|
|
||||||
|
- path: /projects/{id}
|
||||||
|
method: GET
|
||||||
|
scenario: prokov/projects/show
|
||||||
|
|
||||||
|
- path: /projects/{id}
|
||||||
|
method: PUT
|
||||||
|
scenario: prokov/projects/update
|
||||||
|
|
||||||
|
- path: /projects/{id}
|
||||||
|
method: DELETE
|
||||||
|
scenario: prokov/projects/delete
|
||||||
|
|
||||||
|
# === TASKS ===
|
||||||
|
- path: /tasks
|
||||||
|
method: GET
|
||||||
|
scenario: prokov/tasks/list
|
||||||
|
|
||||||
|
- path: /tasks
|
||||||
|
method: POST
|
||||||
|
scenario: prokov/tasks/create
|
||||||
|
|
||||||
|
- path: /tasks/{id}
|
||||||
|
method: GET
|
||||||
|
scenario: prokov/tasks/show
|
||||||
|
|
||||||
|
- path: /tasks/{id}
|
||||||
|
method: PUT
|
||||||
|
scenario: prokov/tasks/update
|
||||||
|
|
||||||
|
- path: /tasks/{id}
|
||||||
|
method: DELETE
|
||||||
|
scenario: prokov/tasks/delete
|
||||||
|
|
||||||
|
# === TAGS ===
|
||||||
|
- path: /tags
|
||||||
|
method: GET
|
||||||
|
scenario: prokov/tags/list
|
||||||
|
|
||||||
|
- path: /tags
|
||||||
|
method: POST
|
||||||
|
scenario: prokov/tags/create
|
||||||
|
|
||||||
|
- path: /tags/{id}
|
||||||
|
method: GET
|
||||||
|
scenario: prokov/tags/show
|
||||||
|
|
||||||
|
- path: /tags/{id}
|
||||||
|
method: PUT
|
||||||
|
scenario: prokov/tags/update
|
||||||
|
|
||||||
|
- path: /tags/{id}
|
||||||
|
method: DELETE
|
||||||
|
scenario: prokov/tags/delete
|
||||||
|
|
||||||
|
# === STATUSES ===
|
||||||
|
- path: /statuses
|
||||||
|
method: GET
|
||||||
|
scenario: prokov/statuses/list
|
||||||
|
|
||||||
|
- path: /statuses
|
||||||
|
method: POST
|
||||||
|
scenario: prokov/statuses/create
|
||||||
|
|
||||||
|
- path: /statuses/{id}
|
||||||
|
method: GET
|
||||||
|
scenario: prokov/statuses/show
|
||||||
|
|
||||||
|
- path: /statuses/{id}
|
||||||
|
method: PUT
|
||||||
|
scenario: prokov/statuses/update
|
||||||
|
|
||||||
|
- path: /statuses/{id}
|
||||||
|
method: DELETE
|
||||||
|
scenario: prokov/statuses/delete
|
||||||
58
config/scenarios/prokov/auth/login.yaml
Normal file
58
config/scenarios/prokov/auth/login.yaml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Scénario: Connexion utilisateur
|
||||||
|
name: login
|
||||||
|
version: "1.0"
|
||||||
|
description: Authentifie un utilisateur et retourne un JWT
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- email
|
||||||
|
- password
|
||||||
|
validation:
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
min_length: 1
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: get_user
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT id, email, name, password FROM users WHERE email = ?"
|
||||||
|
args: ["{{input.email}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Email ou mot de passe incorrect"
|
||||||
|
error_status: 401
|
||||||
|
|
||||||
|
- id: verify_password
|
||||||
|
service: auth
|
||||||
|
action: verify_password
|
||||||
|
params:
|
||||||
|
hash: "{{steps.get_user.result.password}}"
|
||||||
|
password: "{{input.password}}"
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Email ou mot de passe incorrect"
|
||||||
|
error_status: 401
|
||||||
|
|
||||||
|
- id: generate_token
|
||||||
|
service: auth
|
||||||
|
action: generate_jwt
|
||||||
|
params:
|
||||||
|
claims:
|
||||||
|
sub: "{{steps.get_user.result.id}}"
|
||||||
|
email: "{{steps.get_user.result.email}}"
|
||||||
|
name: "{{steps.get_user.result.name}}"
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
message: "Connexion réussie"
|
||||||
|
data:
|
||||||
|
token: "{{steps.generate_token.result.token}}"
|
||||||
|
user:
|
||||||
|
id: "{{steps.get_user.result.id}}"
|
||||||
|
email: "{{steps.get_user.result.email}}"
|
||||||
|
name: "{{steps.get_user.result.name}}"
|
||||||
13
config/scenarios/prokov/auth/logout.yaml
Normal file
13
config/scenarios/prokov/auth/logout.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Scénario: Déconnexion
|
||||||
|
name: logout
|
||||||
|
version: "1.0"
|
||||||
|
description: Déconnecte l'utilisateur (côté client, invalide le JWT)
|
||||||
|
|
||||||
|
# Avec JWT stateless, le logout est géré côté client
|
||||||
|
# Ce endpoint existe pour la compatibilité API
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
message: "Déconnexion réussie"
|
||||||
22
config/scenarios/prokov/auth/me.yaml
Normal file
22
config/scenarios/prokov/auth/me.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Scénario: Récupérer l'utilisateur connecté
|
||||||
|
name: me
|
||||||
|
version: "1.0"
|
||||||
|
description: Retourne les informations de l'utilisateur authentifié
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: get_user
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT id, email, name, created_at FROM users WHERE id = ?"
|
||||||
|
args: ["{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Utilisateur non trouvé"
|
||||||
|
error_status: 404
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
data:
|
||||||
|
user: "{{steps.get_user.result}}"
|
||||||
93
config/scenarios/prokov/auth/register.yaml
Normal file
93
config/scenarios/prokov/auth/register.yaml
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# Scénario: Inscription utilisateur
|
||||||
|
name: register
|
||||||
|
version: "1.0"
|
||||||
|
description: Crée un nouvel utilisateur
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- email
|
||||||
|
- password
|
||||||
|
- name
|
||||||
|
validation:
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
max_length: 255
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
min_length: 6
|
||||||
|
max_length: 255
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
min_length: 2
|
||||||
|
max_length: 100
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: check_email
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT id FROM users WHERE email = ?"
|
||||||
|
args: ["{{input.email}}"]
|
||||||
|
on_success: abort
|
||||||
|
error_message: "Cet email est déjà utilisé"
|
||||||
|
error_status: 409
|
||||||
|
|
||||||
|
- id: hash_password
|
||||||
|
service: auth
|
||||||
|
action: hash_password
|
||||||
|
params:
|
||||||
|
password: "{{input.password}}"
|
||||||
|
|
||||||
|
- id: create_user
|
||||||
|
service: db
|
||||||
|
action: insert
|
||||||
|
params:
|
||||||
|
table: users
|
||||||
|
data:
|
||||||
|
email: "{{input.email}}"
|
||||||
|
password: "{{steps.hash_password.result.hash}}"
|
||||||
|
name: "{{input.name}}"
|
||||||
|
|
||||||
|
- id: create_default_statuses
|
||||||
|
service: db
|
||||||
|
action: exec
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
INSERT INTO statuses (user_id, project_id, code, name, color, position) VALUES
|
||||||
|
(?, NULL, 10, 'Backlog', '#6B7280', 10),
|
||||||
|
(?, NULL, 20, 'À faire', '#3B82F6', 20),
|
||||||
|
(?, NULL, 30, 'En cours', '#F59E0B', 30),
|
||||||
|
(?, NULL, 40, 'À tester', '#8B5CF6', 40),
|
||||||
|
(?, NULL, 50, 'Livré', '#10B981', 50),
|
||||||
|
(?, NULL, 60, 'Terminé', '#059669', 60),
|
||||||
|
(?, NULL, 70, 'Archivé', '#9CA3AF', 70)
|
||||||
|
args:
|
||||||
|
- "{{steps.create_user.insert_id}}"
|
||||||
|
- "{{steps.create_user.insert_id}}"
|
||||||
|
- "{{steps.create_user.insert_id}}"
|
||||||
|
- "{{steps.create_user.insert_id}}"
|
||||||
|
- "{{steps.create_user.insert_id}}"
|
||||||
|
- "{{steps.create_user.insert_id}}"
|
||||||
|
- "{{steps.create_user.insert_id}}"
|
||||||
|
|
||||||
|
- id: generate_token
|
||||||
|
service: auth
|
||||||
|
action: generate_jwt
|
||||||
|
params:
|
||||||
|
claims:
|
||||||
|
sub: "{{steps.create_user.insert_id}}"
|
||||||
|
email: "{{input.email}}"
|
||||||
|
name: "{{input.name}}"
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 201
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
message: "Inscription réussie"
|
||||||
|
data:
|
||||||
|
token: "{{steps.generate_token.result.token}}"
|
||||||
|
user:
|
||||||
|
id: "{{steps.create_user.insert_id}}"
|
||||||
|
email: "{{input.email}}"
|
||||||
|
name: "{{input.name}}"
|
||||||
95
config/scenarios/prokov/projects/create.yaml
Normal file
95
config/scenarios/prokov/projects/create.yaml
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Scénario: Créer un projet
|
||||||
|
name: projects_create
|
||||||
|
version: "1.0"
|
||||||
|
description: Crée un nouveau projet
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
optional:
|
||||||
|
- description
|
||||||
|
- parent_id
|
||||||
|
- position
|
||||||
|
- tags
|
||||||
|
defaults:
|
||||||
|
position: 0
|
||||||
|
validation:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
min_length: 1
|
||||||
|
max_length: 100
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
max_length: 65535
|
||||||
|
parent_id:
|
||||||
|
type: int
|
||||||
|
position:
|
||||||
|
type: int
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: check_parent
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
condition: "{{input.parent_id != null}}"
|
||||||
|
params:
|
||||||
|
query: "SELECT id FROM projects WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.parent_id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Projet parent non trouvé"
|
||||||
|
error_status: 422
|
||||||
|
|
||||||
|
- id: insert_project
|
||||||
|
service: db
|
||||||
|
action: insert
|
||||||
|
params:
|
||||||
|
table: projects
|
||||||
|
data:
|
||||||
|
user_id: "{{auth.user_id}}"
|
||||||
|
parent_id: "{{input.parent_id}}"
|
||||||
|
name: "{{input.name}}"
|
||||||
|
description: "{{input.description}}"
|
||||||
|
position: "{{input.position}}"
|
||||||
|
|
||||||
|
- id: sync_tags
|
||||||
|
service: db
|
||||||
|
action: exec
|
||||||
|
condition: "{{input.tags != null && input.tags | length > 0}}"
|
||||||
|
foreach: "{{input.tags}}"
|
||||||
|
foreach_as: tag_id
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
INSERT INTO project_tags (project_id, tag_id)
|
||||||
|
SELECT ?, id FROM tags WHERE id = ? AND user_id = ?
|
||||||
|
args: ["{{steps.insert_project.insert_id}}", "{{tag_id}}", "{{auth.user_id}}"]
|
||||||
|
|
||||||
|
- id: get_project
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT * FROM projects WHERE id = ?"
|
||||||
|
args: ["{{steps.insert_project.insert_id}}"]
|
||||||
|
|
||||||
|
- id: get_tags
|
||||||
|
service: db
|
||||||
|
action: query
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT t.id, t.name, t.color
|
||||||
|
FROM tags t
|
||||||
|
JOIN project_tags pt ON t.id = pt.tag_id
|
||||||
|
WHERE pt.project_id = ?
|
||||||
|
args: ["{{steps.insert_project.insert_id}}"]
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 201
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
message: "Projet créé"
|
||||||
|
data:
|
||||||
|
id: "{{steps.get_project.result.id}}"
|
||||||
|
name: "{{steps.get_project.result.name}}"
|
||||||
|
description: "{{steps.get_project.result.description}}"
|
||||||
|
parent_id: "{{steps.get_project.result.parent_id}}"
|
||||||
|
position: "{{steps.get_project.result.position}}"
|
||||||
|
created_at: "{{steps.get_project.result.created_at}}"
|
||||||
|
tags: "{{steps.get_tags.result}}"
|
||||||
36
config/scenarios/prokov/projects/delete.yaml
Normal file
36
config/scenarios/prokov/projects/delete.yaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Scénario: Supprimer un projet
|
||||||
|
name: projects_delete
|
||||||
|
version: "1.0"
|
||||||
|
description: Supprime un projet (cascade sur sous-projets et tâches)
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
validation:
|
||||||
|
id:
|
||||||
|
type: int
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: check_project
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT id FROM projects WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Projet non trouvé"
|
||||||
|
error_status: 404
|
||||||
|
|
||||||
|
- id: delete_project
|
||||||
|
service: db
|
||||||
|
action: delete
|
||||||
|
params:
|
||||||
|
table: projects
|
||||||
|
where:
|
||||||
|
id: "{{input.id}}"
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
message: "Projet supprimé"
|
||||||
27
config/scenarios/prokov/projects/list.yaml
Normal file
27
config/scenarios/prokov/projects/list.yaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Scénario: Liste des projets (arborescence)
|
||||||
|
name: projects_list
|
||||||
|
version: "1.0"
|
||||||
|
description: Retourne tous les projets de l'utilisateur en arborescence
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: get_projects
|
||||||
|
service: db
|
||||||
|
action: query
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT p.*,
|
||||||
|
GROUP_CONCAT(t.id) as tag_ids,
|
||||||
|
GROUP_CONCAT(t.name) as tag_names
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN project_tags pt ON p.id = pt.project_id
|
||||||
|
LEFT JOIN tags t ON pt.tag_id = t.id
|
||||||
|
WHERE p.user_id = ?
|
||||||
|
GROUP BY p.id
|
||||||
|
ORDER BY p.parent_id ASC, p.position ASC, p.name ASC
|
||||||
|
args: ["{{auth.user_id}}"]
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
data: "{{steps.get_projects.result | tree}}"
|
||||||
57
config/scenarios/prokov/projects/show.yaml
Normal file
57
config/scenarios/prokov/projects/show.yaml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Scénario: Détail d'un projet
|
||||||
|
name: projects_show
|
||||||
|
version: "1.0"
|
||||||
|
description: Retourne un projet avec ses tags et sous-projets
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
validation:
|
||||||
|
id:
|
||||||
|
type: int
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: get_project
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT * FROM projects WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Projet non trouvé"
|
||||||
|
error_status: 404
|
||||||
|
|
||||||
|
- id: get_tags
|
||||||
|
service: db
|
||||||
|
action: query
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT t.id, t.name, t.color
|
||||||
|
FROM tags t
|
||||||
|
JOIN project_tags pt ON t.id = pt.tag_id
|
||||||
|
WHERE pt.project_id = ?
|
||||||
|
args: ["{{input.id}}"]
|
||||||
|
|
||||||
|
- id: get_children
|
||||||
|
service: db
|
||||||
|
action: query
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT * FROM projects
|
||||||
|
WHERE parent_id = ? AND user_id = ?
|
||||||
|
ORDER BY position ASC, name ASC
|
||||||
|
args: ["{{input.id}}", "{{auth.user_id}}"]
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
data:
|
||||||
|
id: "{{steps.get_project.result.id}}"
|
||||||
|
name: "{{steps.get_project.result.name}}"
|
||||||
|
description: "{{steps.get_project.result.description}}"
|
||||||
|
parent_id: "{{steps.get_project.result.parent_id}}"
|
||||||
|
position: "{{steps.get_project.result.position}}"
|
||||||
|
created_at: "{{steps.get_project.result.created_at}}"
|
||||||
|
tags: "{{steps.get_tags.result}}"
|
||||||
|
children: "{{steps.get_children.result}}"
|
||||||
115
config/scenarios/prokov/projects/update.yaml
Normal file
115
config/scenarios/prokov/projects/update.yaml
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Scénario: Modifier un projet
|
||||||
|
name: projects_update
|
||||||
|
version: "1.0"
|
||||||
|
description: Met à jour un projet existant
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
optional:
|
||||||
|
- name
|
||||||
|
- description
|
||||||
|
- parent_id
|
||||||
|
- position
|
||||||
|
- tags
|
||||||
|
validation:
|
||||||
|
id:
|
||||||
|
type: int
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
min_length: 1
|
||||||
|
max_length: 100
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
max_length: 65535
|
||||||
|
parent_id:
|
||||||
|
type: int
|
||||||
|
position:
|
||||||
|
type: int
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: get_project
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT * FROM projects WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Projet non trouvé"
|
||||||
|
error_status: 404
|
||||||
|
|
||||||
|
- id: check_parent
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
condition: "{{input.parent_id != null && input.parent_id != input.id}}"
|
||||||
|
params:
|
||||||
|
query: "SELECT id FROM projects WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.parent_id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Projet parent invalide"
|
||||||
|
error_status: 422
|
||||||
|
|
||||||
|
- id: update_project
|
||||||
|
service: db
|
||||||
|
action: update
|
||||||
|
params:
|
||||||
|
table: projects
|
||||||
|
where:
|
||||||
|
id: "{{input.id}}"
|
||||||
|
data:
|
||||||
|
name: "{{input.name ?? steps.get_project.result.name}}"
|
||||||
|
description: "{{input.description ?? steps.get_project.result.description}}"
|
||||||
|
parent_id: "{{input.parent_id ?? steps.get_project.result.parent_id}}"
|
||||||
|
position: "{{input.position ?? steps.get_project.result.position}}"
|
||||||
|
|
||||||
|
- id: clear_tags
|
||||||
|
service: db
|
||||||
|
action: delete
|
||||||
|
condition: "{{input.tags != null}}"
|
||||||
|
params:
|
||||||
|
table: project_tags
|
||||||
|
where:
|
||||||
|
project_id: "{{input.id}}"
|
||||||
|
|
||||||
|
- id: sync_tags
|
||||||
|
service: db
|
||||||
|
action: exec
|
||||||
|
condition: "{{input.tags != null && input.tags | length > 0}}"
|
||||||
|
foreach: "{{input.tags}}"
|
||||||
|
foreach_as: tag_id
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
INSERT INTO project_tags (project_id, tag_id)
|
||||||
|
SELECT ?, id FROM tags WHERE id = ? AND user_id = ?
|
||||||
|
args: ["{{input.id}}", "{{tag_id}}", "{{auth.user_id}}"]
|
||||||
|
|
||||||
|
- id: get_updated
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT * FROM projects WHERE id = ?"
|
||||||
|
args: ["{{input.id}}"]
|
||||||
|
|
||||||
|
- id: get_tags
|
||||||
|
service: db
|
||||||
|
action: query
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT t.id, t.name, t.color
|
||||||
|
FROM tags t
|
||||||
|
JOIN project_tags pt ON t.id = pt.tag_id
|
||||||
|
WHERE pt.project_id = ?
|
||||||
|
args: ["{{input.id}}"]
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
message: "Projet mis à jour"
|
||||||
|
data:
|
||||||
|
id: "{{steps.get_updated.result.id}}"
|
||||||
|
name: "{{steps.get_updated.result.name}}"
|
||||||
|
description: "{{steps.get_updated.result.description}}"
|
||||||
|
parent_id: "{{steps.get_updated.result.parent_id}}"
|
||||||
|
position: "{{steps.get_updated.result.position}}"
|
||||||
|
tags: "{{steps.get_tags.result}}"
|
||||||
68
config/scenarios/prokov/statuses/create.yaml
Normal file
68
config/scenarios/prokov/statuses/create.yaml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Scénario: Créer un statut
|
||||||
|
name: statuses_create
|
||||||
|
version: "1.0"
|
||||||
|
description: Crée un nouveau statut
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- code
|
||||||
|
- name
|
||||||
|
optional:
|
||||||
|
- color
|
||||||
|
- project_id
|
||||||
|
- position
|
||||||
|
defaults:
|
||||||
|
color: "#6B7280"
|
||||||
|
validation:
|
||||||
|
code:
|
||||||
|
type: int
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
min_length: 1
|
||||||
|
max_length: 50
|
||||||
|
color:
|
||||||
|
type: string
|
||||||
|
max_length: 7
|
||||||
|
project_id:
|
||||||
|
type: int
|
||||||
|
position:
|
||||||
|
type: int
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: check_project
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
condition: "{{input.project_id != null}}"
|
||||||
|
params:
|
||||||
|
query: "SELECT id FROM projects WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.project_id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Projet invalide"
|
||||||
|
error_status: 422
|
||||||
|
|
||||||
|
- id: insert_status
|
||||||
|
service: db
|
||||||
|
action: insert
|
||||||
|
params:
|
||||||
|
table: statuses
|
||||||
|
data:
|
||||||
|
user_id: "{{auth.user_id}}"
|
||||||
|
project_id: "{{input.project_id}}"
|
||||||
|
code: "{{input.code}}"
|
||||||
|
name: "{{input.name}}"
|
||||||
|
color: "{{input.color}}"
|
||||||
|
position: "{{input.position ?? input.code}}"
|
||||||
|
|
||||||
|
- id: get_status
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT * FROM statuses WHERE id = ?"
|
||||||
|
args: ["{{steps.insert_status.insert_id}}"]
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 201
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
message: "Statut créé"
|
||||||
|
data: "{{steps.get_status.result}}"
|
||||||
51
config/scenarios/prokov/statuses/delete.yaml
Normal file
51
config/scenarios/prokov/statuses/delete.yaml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Scénario: Supprimer un statut
|
||||||
|
name: statuses_delete
|
||||||
|
version: "1.0"
|
||||||
|
description: Supprime un statut (si aucune tâche ne l'utilise)
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
validation:
|
||||||
|
id:
|
||||||
|
type: int
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: check_status
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT id FROM statuses WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Statut non trouvé"
|
||||||
|
error_status: 404
|
||||||
|
|
||||||
|
- id: count_tasks
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT COUNT(*) as count FROM tasks WHERE status_id = ?"
|
||||||
|
args: ["{{input.id}}"]
|
||||||
|
|
||||||
|
- id: check_usage
|
||||||
|
service: system
|
||||||
|
action: assert
|
||||||
|
params:
|
||||||
|
condition: "{{steps.count_tasks.result.count == 0}}"
|
||||||
|
error_message: "Impossible de supprimer : {{steps.count_tasks.result.count}} tâche(s) utilisent ce statut"
|
||||||
|
error_status: 409
|
||||||
|
|
||||||
|
- id: delete_status
|
||||||
|
service: db
|
||||||
|
action: delete
|
||||||
|
params:
|
||||||
|
table: statuses
|
||||||
|
where:
|
||||||
|
id: "{{input.id}}"
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
message: "Statut supprimé"
|
||||||
44
config/scenarios/prokov/statuses/list.yaml
Normal file
44
config/scenarios/prokov/statuses/list.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Scénario: Liste des statuts
|
||||||
|
name: statuses_list
|
||||||
|
version: "1.0"
|
||||||
|
description: Retourne les statuts avec filtres optionnels
|
||||||
|
|
||||||
|
input:
|
||||||
|
optional:
|
||||||
|
- project_id
|
||||||
|
- global
|
||||||
|
validation:
|
||||||
|
project_id:
|
||||||
|
type: int
|
||||||
|
global:
|
||||||
|
type: bool
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: get_statuses
|
||||||
|
service: db
|
||||||
|
action: query
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT s.*,
|
||||||
|
(SELECT COUNT(*) FROM tasks t WHERE t.status_id = s.id) as task_count
|
||||||
|
FROM statuses s
|
||||||
|
WHERE s.user_id = ?
|
||||||
|
AND (
|
||||||
|
(? IS NOT NULL AND (s.project_id = ? OR s.project_id IS NULL))
|
||||||
|
OR (? IS NOT NULL AND s.project_id IS NULL)
|
||||||
|
OR (? IS NULL AND ? IS NULL)
|
||||||
|
)
|
||||||
|
ORDER BY s.position ASC, s.code ASC
|
||||||
|
args:
|
||||||
|
- "{{auth.user_id}}"
|
||||||
|
- "{{input.project_id}}"
|
||||||
|
- "{{input.project_id}}"
|
||||||
|
- "{{input.global}}"
|
||||||
|
- "{{input.project_id}}"
|
||||||
|
- "{{input.global}}"
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
data: "{{steps.get_statuses.result}}"
|
||||||
42
config/scenarios/prokov/statuses/show.yaml
Normal file
42
config/scenarios/prokov/statuses/show.yaml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Scénario: Détail d'un statut
|
||||||
|
name: statuses_show
|
||||||
|
version: "1.0"
|
||||||
|
description: Retourne un statut avec le nombre de tâches
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
validation:
|
||||||
|
id:
|
||||||
|
type: int
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: get_status
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT * FROM statuses WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Statut non trouvé"
|
||||||
|
error_status: 404
|
||||||
|
|
||||||
|
- id: count_tasks
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT COUNT(*) as count FROM tasks WHERE status_id = ?"
|
||||||
|
args: ["{{input.id}}"]
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
data:
|
||||||
|
id: "{{steps.get_status.result.id}}"
|
||||||
|
code: "{{steps.get_status.result.code}}"
|
||||||
|
name: "{{steps.get_status.result.name}}"
|
||||||
|
color: "{{steps.get_status.result.color}}"
|
||||||
|
project_id: "{{steps.get_status.result.project_id}}"
|
||||||
|
position: "{{steps.get_status.result.position}}"
|
||||||
|
task_count: "{{steps.count_tasks.result.count}}"
|
||||||
65
config/scenarios/prokov/statuses/update.yaml
Normal file
65
config/scenarios/prokov/statuses/update.yaml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Scénario: Modifier un statut
|
||||||
|
name: statuses_update
|
||||||
|
version: "1.0"
|
||||||
|
description: Met à jour un statut existant
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
optional:
|
||||||
|
- code
|
||||||
|
- name
|
||||||
|
- color
|
||||||
|
- position
|
||||||
|
validation:
|
||||||
|
id:
|
||||||
|
type: int
|
||||||
|
code:
|
||||||
|
type: int
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
min_length: 1
|
||||||
|
max_length: 50
|
||||||
|
color:
|
||||||
|
type: string
|
||||||
|
max_length: 7
|
||||||
|
position:
|
||||||
|
type: int
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: get_status
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT * FROM statuses WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Statut non trouvé"
|
||||||
|
error_status: 404
|
||||||
|
|
||||||
|
- id: update_status
|
||||||
|
service: db
|
||||||
|
action: update
|
||||||
|
params:
|
||||||
|
table: statuses
|
||||||
|
where:
|
||||||
|
id: "{{input.id}}"
|
||||||
|
data:
|
||||||
|
code: "{{input.code ?? steps.get_status.result.code}}"
|
||||||
|
name: "{{input.name ?? steps.get_status.result.name}}"
|
||||||
|
color: "{{input.color ?? steps.get_status.result.color}}"
|
||||||
|
position: "{{input.position ?? steps.get_status.result.position}}"
|
||||||
|
|
||||||
|
- id: get_updated
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT * FROM statuses WHERE id = ?"
|
||||||
|
args: ["{{input.id}}"]
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
message: "Statut mis à jour"
|
||||||
|
data: "{{steps.get_updated.result}}"
|
||||||
55
config/scenarios/prokov/tags/create.yaml
Normal file
55
config/scenarios/prokov/tags/create.yaml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Scénario: Créer un tag
|
||||||
|
name: tags_create
|
||||||
|
version: "1.0"
|
||||||
|
description: Crée un nouveau tag
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
optional:
|
||||||
|
- color
|
||||||
|
defaults:
|
||||||
|
color: "#3B82F6"
|
||||||
|
validation:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
min_length: 1
|
||||||
|
max_length: 50
|
||||||
|
color:
|
||||||
|
type: string
|
||||||
|
max_length: 7
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: check_unique
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT id FROM tags WHERE user_id = ? AND name = ?"
|
||||||
|
args: ["{{auth.user_id}}", "{{input.name}}"]
|
||||||
|
on_success: abort
|
||||||
|
error_message: "Ce tag existe déjà"
|
||||||
|
error_status: 409
|
||||||
|
|
||||||
|
- id: insert_tag
|
||||||
|
service: db
|
||||||
|
action: insert
|
||||||
|
params:
|
||||||
|
table: tags
|
||||||
|
data:
|
||||||
|
user_id: "{{auth.user_id}}"
|
||||||
|
name: "{{input.name}}"
|
||||||
|
color: "{{input.color}}"
|
||||||
|
|
||||||
|
- id: get_tag
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT * FROM tags WHERE id = ?"
|
||||||
|
args: ["{{steps.insert_tag.insert_id}}"]
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 201
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
message: "Tag créé"
|
||||||
|
data: "{{steps.get_tag.result}}"
|
||||||
36
config/scenarios/prokov/tags/delete.yaml
Normal file
36
config/scenarios/prokov/tags/delete.yaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Scénario: Supprimer un tag
|
||||||
|
name: tags_delete
|
||||||
|
version: "1.0"
|
||||||
|
description: Supprime un tag (les associations sont supprimées en cascade)
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
validation:
|
||||||
|
id:
|
||||||
|
type: int
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: check_tag
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT id FROM tags WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Tag non trouvé"
|
||||||
|
error_status: 404
|
||||||
|
|
||||||
|
- id: delete_tag
|
||||||
|
service: db
|
||||||
|
action: delete
|
||||||
|
params:
|
||||||
|
table: tags
|
||||||
|
where:
|
||||||
|
id: "{{input.id}}"
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
message: "Tag supprimé"
|
||||||
24
config/scenarios/prokov/tags/list.yaml
Normal file
24
config/scenarios/prokov/tags/list.yaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Scénario: Liste des tags
|
||||||
|
name: tags_list
|
||||||
|
version: "1.0"
|
||||||
|
description: Retourne tous les tags de l'utilisateur avec compteurs
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: get_tags
|
||||||
|
service: db
|
||||||
|
action: query
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT t.*,
|
||||||
|
(SELECT COUNT(*) FROM project_tags pt WHERE pt.tag_id = t.id) as project_count,
|
||||||
|
(SELECT COUNT(*) FROM task_tags tt WHERE tt.tag_id = t.id) as task_count
|
||||||
|
FROM tags t
|
||||||
|
WHERE t.user_id = ?
|
||||||
|
ORDER BY t.name ASC
|
||||||
|
args: ["{{auth.user_id}}"]
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
data: "{{steps.get_tags.result}}"
|
||||||
58
config/scenarios/prokov/tags/show.yaml
Normal file
58
config/scenarios/prokov/tags/show.yaml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Scénario: Détail d'un tag
|
||||||
|
name: tags_show
|
||||||
|
version: "1.0"
|
||||||
|
description: Retourne un tag avec ses projets et tâches associés
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
validation:
|
||||||
|
id:
|
||||||
|
type: int
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: get_tag
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT * FROM tags WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Tag non trouvé"
|
||||||
|
error_status: 404
|
||||||
|
|
||||||
|
- id: get_projects
|
||||||
|
service: db
|
||||||
|
action: query
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT p.id, p.name
|
||||||
|
FROM projects p
|
||||||
|
JOIN project_tags pt ON p.id = pt.project_id
|
||||||
|
WHERE pt.tag_id = ?
|
||||||
|
ORDER BY p.name ASC
|
||||||
|
args: ["{{input.id}}"]
|
||||||
|
|
||||||
|
- id: get_tasks
|
||||||
|
service: db
|
||||||
|
action: query
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT t.id, t.title, t.status_id, s.name as status_name
|
||||||
|
FROM tasks t
|
||||||
|
JOIN task_tags tt ON t.id = tt.task_id
|
||||||
|
LEFT JOIN statuses s ON t.status_id = s.id
|
||||||
|
WHERE tt.tag_id = ?
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
args: ["{{input.id}}"]
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
data:
|
||||||
|
id: "{{steps.get_tag.result.id}}"
|
||||||
|
name: "{{steps.get_tag.result.name}}"
|
||||||
|
color: "{{steps.get_tag.result.color}}"
|
||||||
|
projects: "{{steps.get_projects.result}}"
|
||||||
|
tasks: "{{steps.get_tasks.result}}"
|
||||||
68
config/scenarios/prokov/tags/update.yaml
Normal file
68
config/scenarios/prokov/tags/update.yaml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Scénario: Modifier un tag
|
||||||
|
name: tags_update
|
||||||
|
version: "1.0"
|
||||||
|
description: Met à jour un tag existant
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
optional:
|
||||||
|
- name
|
||||||
|
- color
|
||||||
|
validation:
|
||||||
|
id:
|
||||||
|
type: int
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
min_length: 1
|
||||||
|
max_length: 50
|
||||||
|
color:
|
||||||
|
type: string
|
||||||
|
max_length: 7
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: get_tag
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT * FROM tags WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Tag non trouvé"
|
||||||
|
error_status: 404
|
||||||
|
|
||||||
|
- id: check_unique
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
condition: "{{input.name != null}}"
|
||||||
|
params:
|
||||||
|
query: "SELECT id FROM tags WHERE user_id = ? AND name = ? AND id != ?"
|
||||||
|
args: ["{{auth.user_id}}", "{{input.name}}", "{{input.id}}"]
|
||||||
|
on_success: abort
|
||||||
|
error_message: "Ce tag existe déjà"
|
||||||
|
error_status: 409
|
||||||
|
|
||||||
|
- id: update_tag
|
||||||
|
service: db
|
||||||
|
action: update
|
||||||
|
params:
|
||||||
|
table: tags
|
||||||
|
where:
|
||||||
|
id: "{{input.id}}"
|
||||||
|
data:
|
||||||
|
name: "{{input.name ?? steps.get_tag.result.name}}"
|
||||||
|
color: "{{input.color ?? steps.get_tag.result.color}}"
|
||||||
|
|
||||||
|
- id: get_updated
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT * FROM tags WHERE id = ?"
|
||||||
|
args: ["{{input.id}}"]
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
message: "Tag mis à jour"
|
||||||
|
data: "{{steps.get_updated.result}}"
|
||||||
135
config/scenarios/prokov/tasks/create.yaml
Normal file
135
config/scenarios/prokov/tasks/create.yaml
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Scénario: Créer une tâche
|
||||||
|
name: tasks_create
|
||||||
|
version: "1.0"
|
||||||
|
description: Crée une nouvelle tâche
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- project_id
|
||||||
|
- status_id
|
||||||
|
- title
|
||||||
|
optional:
|
||||||
|
- description
|
||||||
|
- priority
|
||||||
|
- date_start
|
||||||
|
- date_end
|
||||||
|
- time_estimated
|
||||||
|
- time_spent
|
||||||
|
- billing
|
||||||
|
- position
|
||||||
|
- tags
|
||||||
|
defaults:
|
||||||
|
priority: 5
|
||||||
|
time_estimated: 0
|
||||||
|
time_spent: 0
|
||||||
|
billing: 0
|
||||||
|
position: 0
|
||||||
|
validation:
|
||||||
|
project_id:
|
||||||
|
type: int
|
||||||
|
status_id:
|
||||||
|
type: int
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
min_length: 1
|
||||||
|
max_length: 255
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
max_length: 65535
|
||||||
|
priority:
|
||||||
|
type: int
|
||||||
|
date_start:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
date_end:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: check_project
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT id FROM projects WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.project_id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Projet invalide"
|
||||||
|
error_status: 422
|
||||||
|
|
||||||
|
- id: check_status
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT id FROM statuses WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.status_id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Statut invalide"
|
||||||
|
error_status: 422
|
||||||
|
|
||||||
|
- id: insert_task
|
||||||
|
service: db
|
||||||
|
action: insert
|
||||||
|
params:
|
||||||
|
table: tasks
|
||||||
|
data:
|
||||||
|
user_id: "{{auth.user_id}}"
|
||||||
|
project_id: "{{input.project_id}}"
|
||||||
|
status_id: "{{input.status_id}}"
|
||||||
|
title: "{{input.title}}"
|
||||||
|
description: "{{input.description}}"
|
||||||
|
priority: "{{input.priority}}"
|
||||||
|
date_start: "{{input.date_start}}"
|
||||||
|
date_end: "{{input.date_end}}"
|
||||||
|
time_estimated: "{{input.time_estimated}}"
|
||||||
|
time_spent: "{{input.time_spent}}"
|
||||||
|
billing: "{{input.billing}}"
|
||||||
|
position: "{{input.position}}"
|
||||||
|
|
||||||
|
- id: sync_tags
|
||||||
|
service: db
|
||||||
|
action: exec
|
||||||
|
condition: "{{input.tags != null && input.tags | length > 0}}"
|
||||||
|
foreach: "{{input.tags}}"
|
||||||
|
foreach_as: tag_id
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
INSERT INTO task_tags (task_id, tag_id)
|
||||||
|
SELECT ?, id FROM tags WHERE id = ? AND user_id = ?
|
||||||
|
args: ["{{steps.insert_task.insert_id}}", "{{tag_id}}", "{{auth.user_id}}"]
|
||||||
|
|
||||||
|
- id: get_task
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT t.*, p.name as project_name, s.name as status_name, s.color as status_color
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN projects p ON t.project_id = p.id
|
||||||
|
LEFT JOIN statuses s ON t.status_id = s.id
|
||||||
|
WHERE t.id = ?
|
||||||
|
args: ["{{steps.insert_task.insert_id}}"]
|
||||||
|
|
||||||
|
- id: get_tags
|
||||||
|
service: db
|
||||||
|
action: query
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT t.id, t.name, t.color
|
||||||
|
FROM tags t
|
||||||
|
JOIN task_tags tt ON t.id = tt.tag_id
|
||||||
|
WHERE tt.task_id = ?
|
||||||
|
args: ["{{steps.insert_task.insert_id}}"]
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 201
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
message: "Tâche créée"
|
||||||
|
data:
|
||||||
|
id: "{{steps.get_task.result.id}}"
|
||||||
|
project_id: "{{steps.get_task.result.project_id}}"
|
||||||
|
project_name: "{{steps.get_task.result.project_name}}"
|
||||||
|
status_id: "{{steps.get_task.result.status_id}}"
|
||||||
|
status_name: "{{steps.get_task.result.status_name}}"
|
||||||
|
title: "{{steps.get_task.result.title}}"
|
||||||
|
tags: "{{steps.get_tags.result}}"
|
||||||
36
config/scenarios/prokov/tasks/delete.yaml
Normal file
36
config/scenarios/prokov/tasks/delete.yaml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Scénario: Supprimer une tâche
|
||||||
|
name: tasks_delete
|
||||||
|
version: "1.0"
|
||||||
|
description: Supprime une tâche
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
validation:
|
||||||
|
id:
|
||||||
|
type: int
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: check_task
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT id FROM tasks WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Tâche non trouvée"
|
||||||
|
error_status: 404
|
||||||
|
|
||||||
|
- id: delete_task
|
||||||
|
service: db
|
||||||
|
action: delete
|
||||||
|
params:
|
||||||
|
table: tasks
|
||||||
|
where:
|
||||||
|
id: "{{input.id}}"
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
message: "Tâche supprimée"
|
||||||
67
config/scenarios/prokov/tasks/list.yaml
Normal file
67
config/scenarios/prokov/tasks/list.yaml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Scénario: Liste des tâches
|
||||||
|
name: tasks_list
|
||||||
|
version: "1.0"
|
||||||
|
description: Retourne les tâches avec filtres optionnels
|
||||||
|
|
||||||
|
input:
|
||||||
|
optional:
|
||||||
|
- project_id
|
||||||
|
- status_id
|
||||||
|
- tag_id
|
||||||
|
- date_start
|
||||||
|
- date_end
|
||||||
|
validation:
|
||||||
|
project_id:
|
||||||
|
type: int
|
||||||
|
status_id:
|
||||||
|
type: int
|
||||||
|
tag_id:
|
||||||
|
type: int
|
||||||
|
date_start:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
date_end:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: get_tasks
|
||||||
|
service: db
|
||||||
|
action: query
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT t.*,
|
||||||
|
p.name as project_name,
|
||||||
|
s.name as status_name,
|
||||||
|
s.color as status_color,
|
||||||
|
GROUP_CONCAT(tg.id) as tag_ids,
|
||||||
|
GROUP_CONCAT(tg.name) as tag_names,
|
||||||
|
GROUP_CONCAT(tg.color) as tag_colors
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN projects p ON t.project_id = p.id
|
||||||
|
LEFT JOIN statuses s ON t.status_id = s.id
|
||||||
|
LEFT JOIN task_tags tt ON t.id = tt.task_id
|
||||||
|
LEFT JOIN tags tg ON tt.tag_id = tg.id
|
||||||
|
WHERE t.user_id = ?
|
||||||
|
AND (? IS NULL OR t.project_id = ?)
|
||||||
|
AND (? IS NULL OR t.status_id = ?)
|
||||||
|
AND (? IS NULL OR t.date_start >= ?)
|
||||||
|
AND (? IS NULL OR t.date_end <= ?)
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY t.position ASC, t.priority DESC, t.created_at DESC
|
||||||
|
args:
|
||||||
|
- "{{auth.user_id}}"
|
||||||
|
- "{{input.project_id}}"
|
||||||
|
- "{{input.project_id}}"
|
||||||
|
- "{{input.status_id}}"
|
||||||
|
- "{{input.status_id}}"
|
||||||
|
- "{{input.date_start}}"
|
||||||
|
- "{{input.date_start}}"
|
||||||
|
- "{{input.date_end}}"
|
||||||
|
- "{{input.date_end}}"
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
data: "{{steps.get_tasks.result | parse_tags}}"
|
||||||
61
config/scenarios/prokov/tasks/show.yaml
Normal file
61
config/scenarios/prokov/tasks/show.yaml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Scénario: Détail d'une tâche
|
||||||
|
name: tasks_show
|
||||||
|
version: "1.0"
|
||||||
|
description: Retourne une tâche avec ses tags
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
validation:
|
||||||
|
id:
|
||||||
|
type: int
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: get_task
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT t.*, p.name as project_name, s.name as status_name, s.color as status_color
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN projects p ON t.project_id = p.id
|
||||||
|
LEFT JOIN statuses s ON t.status_id = s.id
|
||||||
|
WHERE t.id = ? AND t.user_id = ?
|
||||||
|
args: ["{{input.id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Tâche non trouvée"
|
||||||
|
error_status: 404
|
||||||
|
|
||||||
|
- id: get_tags
|
||||||
|
service: db
|
||||||
|
action: query
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT t.id, t.name, t.color
|
||||||
|
FROM tags t
|
||||||
|
JOIN task_tags tt ON t.id = tt.tag_id
|
||||||
|
WHERE tt.task_id = ?
|
||||||
|
args: ["{{input.id}}"]
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
data:
|
||||||
|
id: "{{steps.get_task.result.id}}"
|
||||||
|
project_id: "{{steps.get_task.result.project_id}}"
|
||||||
|
project_name: "{{steps.get_task.result.project_name}}"
|
||||||
|
status_id: "{{steps.get_task.result.status_id}}"
|
||||||
|
status_name: "{{steps.get_task.result.status_name}}"
|
||||||
|
status_color: "{{steps.get_task.result.status_color}}"
|
||||||
|
title: "{{steps.get_task.result.title}}"
|
||||||
|
description: "{{steps.get_task.result.description}}"
|
||||||
|
priority: "{{steps.get_task.result.priority}}"
|
||||||
|
date_start: "{{steps.get_task.result.date_start}}"
|
||||||
|
date_end: "{{steps.get_task.result.date_end}}"
|
||||||
|
time_estimated: "{{steps.get_task.result.time_estimated}}"
|
||||||
|
time_spent: "{{steps.get_task.result.time_spent}}"
|
||||||
|
billing: "{{steps.get_task.result.billing}}"
|
||||||
|
position: "{{steps.get_task.result.position}}"
|
||||||
|
created_at: "{{steps.get_task.result.created_at}}"
|
||||||
|
tags: "{{steps.get_tags.result}}"
|
||||||
140
config/scenarios/prokov/tasks/update.yaml
Normal file
140
config/scenarios/prokov/tasks/update.yaml
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Scénario: Modifier une tâche
|
||||||
|
name: tasks_update
|
||||||
|
version: "1.0"
|
||||||
|
description: Met à jour une tâche existante
|
||||||
|
|
||||||
|
input:
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
optional:
|
||||||
|
- project_id
|
||||||
|
- status_id
|
||||||
|
- title
|
||||||
|
- description
|
||||||
|
- priority
|
||||||
|
- date_start
|
||||||
|
- date_end
|
||||||
|
- time_estimated
|
||||||
|
- time_spent
|
||||||
|
- billing
|
||||||
|
- position
|
||||||
|
- tags
|
||||||
|
validation:
|
||||||
|
id:
|
||||||
|
type: int
|
||||||
|
project_id:
|
||||||
|
type: int
|
||||||
|
status_id:
|
||||||
|
type: int
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
min_length: 1
|
||||||
|
max_length: 255
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: get_task
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: "SELECT * FROM tasks WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Tâche non trouvée"
|
||||||
|
error_status: 404
|
||||||
|
|
||||||
|
- id: check_project
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
condition: "{{input.project_id != null}}"
|
||||||
|
params:
|
||||||
|
query: "SELECT id FROM projects WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.project_id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Projet invalide"
|
||||||
|
error_status: 422
|
||||||
|
|
||||||
|
- id: check_status
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
condition: "{{input.status_id != null}}"
|
||||||
|
params:
|
||||||
|
query: "SELECT id FROM statuses WHERE id = ? AND user_id = ?"
|
||||||
|
args: ["{{input.status_id}}", "{{auth.user_id}}"]
|
||||||
|
on_error: abort
|
||||||
|
error_message: "Statut invalide"
|
||||||
|
error_status: 422
|
||||||
|
|
||||||
|
- id: update_task
|
||||||
|
service: db
|
||||||
|
action: update
|
||||||
|
params:
|
||||||
|
table: tasks
|
||||||
|
where:
|
||||||
|
id: "{{input.id}}"
|
||||||
|
data:
|
||||||
|
project_id: "{{input.project_id ?? steps.get_task.result.project_id}}"
|
||||||
|
status_id: "{{input.status_id ?? steps.get_task.result.status_id}}"
|
||||||
|
title: "{{input.title ?? steps.get_task.result.title}}"
|
||||||
|
description: "{{input.description ?? steps.get_task.result.description}}"
|
||||||
|
priority: "{{input.priority ?? steps.get_task.result.priority}}"
|
||||||
|
date_start: "{{input.date_start ?? steps.get_task.result.date_start}}"
|
||||||
|
date_end: "{{input.date_end ?? steps.get_task.result.date_end}}"
|
||||||
|
time_estimated: "{{input.time_estimated ?? steps.get_task.result.time_estimated}}"
|
||||||
|
time_spent: "{{input.time_spent ?? steps.get_task.result.time_spent}}"
|
||||||
|
billing: "{{input.billing ?? steps.get_task.result.billing}}"
|
||||||
|
position: "{{input.position ?? steps.get_task.result.position}}"
|
||||||
|
|
||||||
|
- id: clear_tags
|
||||||
|
service: db
|
||||||
|
action: delete
|
||||||
|
condition: "{{input.tags != null}}"
|
||||||
|
params:
|
||||||
|
table: task_tags
|
||||||
|
where:
|
||||||
|
task_id: "{{input.id}}"
|
||||||
|
|
||||||
|
- id: sync_tags
|
||||||
|
service: db
|
||||||
|
action: exec
|
||||||
|
condition: "{{input.tags != null && input.tags | length > 0}}"
|
||||||
|
foreach: "{{input.tags}}"
|
||||||
|
foreach_as: tag_id
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
INSERT INTO task_tags (task_id, tag_id)
|
||||||
|
SELECT ?, id FROM tags WHERE id = ? AND user_id = ?
|
||||||
|
args: ["{{input.id}}", "{{tag_id}}", "{{auth.user_id}}"]
|
||||||
|
|
||||||
|
- id: get_updated
|
||||||
|
service: db
|
||||||
|
action: query_one
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT t.*, p.name as project_name, s.name as status_name, s.color as status_color
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN projects p ON t.project_id = p.id
|
||||||
|
LEFT JOIN statuses s ON t.status_id = s.id
|
||||||
|
WHERE t.id = ?
|
||||||
|
args: ["{{input.id}}"]
|
||||||
|
|
||||||
|
- id: get_tags
|
||||||
|
service: db
|
||||||
|
action: query
|
||||||
|
params:
|
||||||
|
query: |
|
||||||
|
SELECT t.id, t.name, t.color
|
||||||
|
FROM tags t
|
||||||
|
JOIN task_tags tt ON t.id = tt.tag_id
|
||||||
|
WHERE tt.task_id = ?
|
||||||
|
args: ["{{input.id}}"]
|
||||||
|
|
||||||
|
output:
|
||||||
|
status: 200
|
||||||
|
body:
|
||||||
|
success: true
|
||||||
|
message: "Tâche mise à jour"
|
||||||
|
data:
|
||||||
|
id: "{{steps.get_updated.result.id}}"
|
||||||
|
title: "{{steps.get_updated.result.title}}"
|
||||||
|
status_name: "{{steps.get_updated.result.status_name}}"
|
||||||
|
tags: "{{steps.get_tags.result}}"
|
||||||
29
config/sogoctl.yaml
Normal file
29
config/sogoctl.yaml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Configuration du superviseur sogoctl
|
||||||
|
|
||||||
|
supervisor:
|
||||||
|
health_interval: 10s
|
||||||
|
restart_delay: 2s
|
||||||
|
max_restarts: 5
|
||||||
|
|
||||||
|
services:
|
||||||
|
sogoms-db:
|
||||||
|
binary: /opt/sogoms/bin/sogoms-db
|
||||||
|
args:
|
||||||
|
- "-config"
|
||||||
|
- "/config"
|
||||||
|
- "-socket"
|
||||||
|
- "/run/sogoms-db.1.sock"
|
||||||
|
health_socket: /run/sogoms-db.1.sock
|
||||||
|
|
||||||
|
sogoway:
|
||||||
|
binary: /opt/sogoms/bin/sogoway
|
||||||
|
args:
|
||||||
|
- "-config"
|
||||||
|
- "/config"
|
||||||
|
- "-port"
|
||||||
|
- "8080"
|
||||||
|
- "-db-socket"
|
||||||
|
- "/run/sogoms-db.1.sock"
|
||||||
|
health_url: http://localhost:8080/health
|
||||||
|
depends_on:
|
||||||
|
- sogoms-db
|
||||||
154
deploy.sh
Executable file
154
deploy.sh
Executable file
@@ -0,0 +1,154 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Script de déploiement pour SOGOMS
|
||||||
|
# Version: 1.0 (15 décembre 2025)
|
||||||
|
# Auteur: Pierre (avec l'aide de Claude)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Configuration SSH
|
||||||
|
JUMP_USER="root"
|
||||||
|
JUMP_HOST="195.154.80.116"
|
||||||
|
JUMP_PORT="22"
|
||||||
|
JUMP_KEY="/home/pierre/.ssh/id_rsa_mbpi"
|
||||||
|
|
||||||
|
# Configuration Incus
|
||||||
|
INCUS_PROJECT="default"
|
||||||
|
INCUS_CONTAINER="gw3"
|
||||||
|
CONTAINER_IP="13.23.33.5"
|
||||||
|
|
||||||
|
# Chemins sur le container
|
||||||
|
REMOTE_BIN="/opt/sogoms/bin"
|
||||||
|
REMOTE_CONFIG="/config"
|
||||||
|
REMOTE_SECRETS="/secrets"
|
||||||
|
|
||||||
|
# Couleurs pour les messages
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
YELLOW='\033[0;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Fonctions d'affichage
|
||||||
|
echo_step() {
|
||||||
|
echo -e "${GREEN}==>${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo_info() {
|
||||||
|
echo -e "${BLUE}Info:${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo_warning() {
|
||||||
|
echo -e "${YELLOW}Warning:${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo_error() {
|
||||||
|
echo -e "${RED}Error:${NC} $1"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Répertoire du script
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Vérification de l'environnement
|
||||||
|
echo_step "Verifying environment..."
|
||||||
|
echo_info "Deploying SOGOMS to container $INCUS_CONTAINER ($CONTAINER_IP)"
|
||||||
|
echo_info "Jump host: $JUMP_HOST"
|
||||||
|
|
||||||
|
if [ ! -d "cmd/sogoms/db" ] || [ ! -d "cmd/sogoway" ] || [ ! -d "cmd/sogoctl" ]; then
|
||||||
|
echo_error "Source directories missing - are you in the sogoms directory?"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "config/routes" ]; then
|
||||||
|
echo_error "config/routes missing"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Commande SSH vers IN3
|
||||||
|
SSH_CMD="ssh -i ${JUMP_KEY} -p ${JUMP_PORT} ${JUMP_USER}@${JUMP_HOST}"
|
||||||
|
SCP_CMD="scp -i ${JUMP_KEY} -P ${JUMP_PORT}"
|
||||||
|
|
||||||
|
# Lire la version
|
||||||
|
VERSION=$(cat VERSION | tr -d '\n')
|
||||||
|
BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S')
|
||||||
|
LDFLAGS="-X sogoms.com/internal/version.Version=${VERSION} -X sogoms.com/internal/version.BuildTime=${BUILD_TIME}"
|
||||||
|
|
||||||
|
# Étape 1: Build des binaires
|
||||||
|
echo_step "Building binaries v${VERSION} (linux/amd64)..."
|
||||||
|
mkdir -p bin
|
||||||
|
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoms-db ./cmd/sogoms/db || echo_error "Failed to build sogoms-db"
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoway ./cmd/sogoway || echo_error "Failed to build sogoway"
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o bin/sogoctl ./cmd/sogoctl || echo_error "Failed to build sogoctl"
|
||||||
|
|
||||||
|
echo_info "Built: sogoms-db, sogoway, sogoctl (v${VERSION})"
|
||||||
|
|
||||||
|
# Étape 2: Créer les archives
|
||||||
|
echo_step "Creating archives..."
|
||||||
|
TIMESTAMP=$(date +%s)
|
||||||
|
BIN_ARCHIVE="sogoms-bin-${TIMESTAMP}.tar.gz"
|
||||||
|
CONFIG_ARCHIVE="sogoms-config-${TIMESTAMP}.tar.gz"
|
||||||
|
|
||||||
|
tar -czf "/tmp/${BIN_ARCHIVE}" -C bin . || echo_error "Failed to create bin archive"
|
||||||
|
tar -czf "/tmp/${CONFIG_ARCHIVE}" -C config . || echo_error "Failed to create config archive"
|
||||||
|
|
||||||
|
BIN_SIZE=$(du -h "/tmp/${BIN_ARCHIVE}" | cut -f1)
|
||||||
|
CONFIG_SIZE=$(du -h "/tmp/${CONFIG_ARCHIVE}" | cut -f1)
|
||||||
|
echo_info "Binaries archive: $BIN_SIZE"
|
||||||
|
echo_info "Config archive: $CONFIG_SIZE"
|
||||||
|
|
||||||
|
# Étape 3: Copier vers IN3
|
||||||
|
echo_step "Copying archives to jump server (IN3)..."
|
||||||
|
$SCP_CMD "/tmp/${BIN_ARCHIVE}" "${JUMP_USER}@${JUMP_HOST}:/tmp/" || echo_error "Failed to copy bin archive"
|
||||||
|
$SCP_CMD "/tmp/${CONFIG_ARCHIVE}" "${JUMP_USER}@${JUMP_HOST}:/tmp/" || echo_error "Failed to copy config archive"
|
||||||
|
|
||||||
|
# Étape 4: Déployer dans le container
|
||||||
|
echo_step "Deploying to Incus container ($INCUS_CONTAINER)..."
|
||||||
|
$SSH_CMD "
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo '📦 Switching to Incus project...'
|
||||||
|
incus project switch ${INCUS_PROJECT} || exit 1
|
||||||
|
|
||||||
|
echo '📦 Pushing archives to container...'
|
||||||
|
incus file push /tmp/${BIN_ARCHIVE} ${INCUS_CONTAINER}/tmp/ || exit 1
|
||||||
|
incus file push /tmp/${CONFIG_ARCHIVE} ${INCUS_CONTAINER}/tmp/ || exit 1
|
||||||
|
|
||||||
|
echo '📁 Deploying binaries...'
|
||||||
|
incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_BIN}
|
||||||
|
incus exec ${INCUS_CONTAINER} -- tar -xzvf /tmp/${BIN_ARCHIVE} -C ${REMOTE_BIN}/
|
||||||
|
incus exec ${INCUS_CONTAINER} -- chmod 755 ${REMOTE_BIN}/sogoms-db ${REMOTE_BIN}/sogoway ${REMOTE_BIN}/sogoctl
|
||||||
|
|
||||||
|
echo '📁 Deploying config...'
|
||||||
|
incus exec ${INCUS_CONTAINER} -- mkdir -p ${REMOTE_CONFIG}/routes ${REMOTE_CONFIG}/scenarios
|
||||||
|
incus exec ${INCUS_CONTAINER} -- tar -xzf /tmp/${CONFIG_ARCHIVE} -C ${REMOTE_CONFIG}/
|
||||||
|
|
||||||
|
echo '📁 Setting up run directory...'
|
||||||
|
incus exec ${INCUS_CONTAINER} -- mkdir -p /run
|
||||||
|
|
||||||
|
echo '🧹 Cleanup...'
|
||||||
|
incus exec ${INCUS_CONTAINER} -- rm -f /tmp/${BIN_ARCHIVE} /tmp/${CONFIG_ARCHIVE}
|
||||||
|
rm -f /tmp/${BIN_ARCHIVE} /tmp/${CONFIG_ARCHIVE}
|
||||||
|
"
|
||||||
|
|
||||||
|
# Nettoyage local
|
||||||
|
rm -f "/tmp/${BIN_ARCHIVE}" "/tmp/${CONFIG_ARCHIVE}"
|
||||||
|
|
||||||
|
# Résumé final
|
||||||
|
echo_step "Deployment completed successfully!"
|
||||||
|
echo ""
|
||||||
|
echo_info "SOGOMS v${VERSION} deployed"
|
||||||
|
echo_info " Host: IN3 ($JUMP_HOST)"
|
||||||
|
echo_info " Container: $INCUS_CONTAINER ($CONTAINER_IP)"
|
||||||
|
echo_info " Binaries: $REMOTE_BIN"
|
||||||
|
echo_info " Config: $REMOTE_CONFIG"
|
||||||
|
echo_info " Deployment time: $(date)"
|
||||||
|
echo ""
|
||||||
|
echo_warning "Next steps on gw3:"
|
||||||
|
echo_info " 1. Edit /secrets/prokov_db_pass with real DB password"
|
||||||
|
echo_info " 2. Start services: /opt/sogoms/bin/sogoctl"
|
||||||
|
echo ""
|
||||||
|
echo_info "To connect: ssh in3 -t 'incus exec $INCUS_CONTAINER -- sh'"
|
||||||
|
|
||||||
|
# Journaliser le déploiement
|
||||||
|
echo "$(date '+%Y-%m-%d %H:%M:%S') - SOGOMS v${VERSION} deployed to ${INCUS_CONTAINER} (${CONTAINER_IP})" >> ~/.sogoms_deploy_history
|
||||||
12
go.mod
Executable file
12
go.mod
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
module sogoms.com
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
toolchain go1.24.11
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||||
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
9
go.sum
Normal file
9
go.sum
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
136
internal/auth/jwt.go
Normal file
136
internal/auth/jwt.go
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// Package auth gère l'authentification JWT et les mots de passe.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidToken = errors.New("invalid token")
|
||||||
|
ErrExpiredToken = errors.New("token expired")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Claims représente les claims du JWT.
|
||||||
|
type Claims struct {
|
||||||
|
Sub int64 `json:"sub"` // User ID
|
||||||
|
Email string `json:"email"` // Email
|
||||||
|
Name string `json:"name"` // Nom
|
||||||
|
App string `json:"app"` // Application ID
|
||||||
|
Exp int64 `json:"exp"` // Expiration (Unix timestamp)
|
||||||
|
Iat int64 `json:"iat"` // Issued at
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT gère la génération et validation des tokens.
|
||||||
|
type JWT struct {
|
||||||
|
secret []byte
|
||||||
|
expiration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJWT crée un nouveau gestionnaire JWT.
|
||||||
|
func NewJWT(secret string, expiration time.Duration) *JWT {
|
||||||
|
return &JWT{
|
||||||
|
secret: []byte(secret),
|
||||||
|
expiration: expiration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate génère un nouveau token JWT.
|
||||||
|
func (j *JWT) Generate(userID int64, email, name, appID string) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
claims := Claims{
|
||||||
|
Sub: userID,
|
||||||
|
Email: email,
|
||||||
|
Name: name,
|
||||||
|
App: appID,
|
||||||
|
Iat: now.Unix(),
|
||||||
|
Exp: now.Add(j.expiration).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return j.encode(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate valide un token et retourne les claims.
|
||||||
|
func (j *JWT) Validate(token string) (*Claims, error) {
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier la signature
|
||||||
|
signatureInput := parts[0] + "." + parts[1]
|
||||||
|
expectedSig := j.sign(signatureInput)
|
||||||
|
if parts[2] != expectedSig {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Décoder les claims
|
||||||
|
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims Claims
|
||||||
|
if err := json.Unmarshal(claimsJSON, &claims); err != nil {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier l'expiration
|
||||||
|
if time.Now().Unix() > claims.Exp {
|
||||||
|
return nil, ErrExpiredToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return &claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encode encode les claims en JWT.
|
||||||
|
func (j *JWT) encode(claims Claims) (string, error) {
|
||||||
|
// Header
|
||||||
|
header := map[string]string{
|
||||||
|
"alg": "HS256",
|
||||||
|
"typ": "JWT",
|
||||||
|
}
|
||||||
|
headerJSON, _ := json.Marshal(header)
|
||||||
|
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||||
|
|
||||||
|
// Payload
|
||||||
|
claimsJSON, err := json.Marshal(claims)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
|
||||||
|
|
||||||
|
// Signature
|
||||||
|
signatureInput := headerB64 + "." + claimsB64
|
||||||
|
signature := j.sign(signatureInput)
|
||||||
|
|
||||||
|
return signatureInput + "." + signature, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign signe les données avec HMAC-SHA256.
|
||||||
|
func (j *JWT) sign(data string) string {
|
||||||
|
h := hmac.New(sha256.New, j.secret)
|
||||||
|
h.Write([]byte(data))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractToken extrait le token du header Authorization.
|
||||||
|
func ExtractToken(authHeader string) (string, error) {
|
||||||
|
if authHeader == "" {
|
||||||
|
return "", fmt.Errorf("missing authorization header")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
|
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||||
|
return "", fmt.Errorf("invalid authorization header format")
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts[1], nil
|
||||||
|
}
|
||||||
20
internal/auth/password.go
Normal file
20
internal/auth/password.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashPassword génère un hash bcrypt du mot de passe.
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword vérifie si le mot de passe correspond au hash.
|
||||||
|
func VerifyPassword(hash, password string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
182
internal/config/config.go
Normal file
182
internal/config/config.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// Package config gère le chargement des configurations YAML.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppConfig représente la configuration d'une application cliente.
|
||||||
|
type AppConfig struct {
|
||||||
|
App string `yaml:"app"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
BasePath string `yaml:"base_path"`
|
||||||
|
Hosts []string `yaml:"hosts"`
|
||||||
|
Database Database `yaml:"database"`
|
||||||
|
Auth Auth `yaml:"auth"`
|
||||||
|
Routes []Route `yaml:"routes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth contient la configuration d'authentification.
|
||||||
|
type Auth struct {
|
||||||
|
JWTSecretFile string `yaml:"jwt_secret_file"`
|
||||||
|
JWTExpiry string `yaml:"jwt_expiry"`
|
||||||
|
jwtSecret string // Chargé depuis le fichier
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWTSecret retourne le secret JWT (chargé depuis le fichier).
|
||||||
|
func (a *Auth) JWTSecret() string {
|
||||||
|
return a.jwtSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database contient la configuration de connexion à la base de données.
|
||||||
|
type Database struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
User string `yaml:"user"`
|
||||||
|
PasswordFile string `yaml:"password_file"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
password string // Chargé depuis le fichier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password retourne le mot de passe (chargé depuis le fichier).
|
||||||
|
func (d *Database) Password() string {
|
||||||
|
return d.password
|
||||||
|
}
|
||||||
|
|
||||||
|
// DSN retourne la chaîne de connexion MySQL/MariaDB.
|
||||||
|
func (d *Database) DSN() string {
|
||||||
|
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4",
|
||||||
|
d.User, d.password, d.Host, d.Port, d.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route représente une route API.
|
||||||
|
type Route struct {
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
Method string `yaml:"method"`
|
||||||
|
Scenario string `yaml:"scenario"`
|
||||||
|
Auth *bool `yaml:"auth,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry stocke les configurations des applications.
|
||||||
|
type Registry struct {
|
||||||
|
configDir string
|
||||||
|
apps map[string]*AppConfig // Par app_id
|
||||||
|
byHost map[string]*AppConfig // Par hostname
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry crée un nouveau registre de configurations.
|
||||||
|
func NewRegistry(configDir string) *Registry {
|
||||||
|
return &Registry{
|
||||||
|
configDir: configDir,
|
||||||
|
apps: make(map[string]*AppConfig),
|
||||||
|
byHost: make(map[string]*AppConfig),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load charge toutes les configurations depuis le répertoire routes.
|
||||||
|
func (r *Registry) Load() error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
routesDir := filepath.Join(r.configDir, "routes")
|
||||||
|
entries, err := os.ReadDir(routesDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read routes dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(routesDir, entry.Name())
|
||||||
|
cfg, err := r.loadAppConfig(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load %s: %w", entry.Name(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.apps[cfg.App] = cfg
|
||||||
|
for _, host := range cfg.Hosts {
|
||||||
|
r.byHost[host] = cfg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAppConfig charge une configuration d'application depuis un fichier YAML.
|
||||||
|
func (r *Registry) loadAppConfig(path string) (*AppConfig, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg AppConfig
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger le mot de passe DB depuis le fichier
|
||||||
|
if cfg.Database.PasswordFile != "" {
|
||||||
|
passData, err := os.ReadFile(cfg.Database.PasswordFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read db password file: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Database.password = strings.TrimSpace(string(passData))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charger le secret JWT depuis le fichier
|
||||||
|
if cfg.Auth.JWTSecretFile != "" {
|
||||||
|
secretData, err := os.ReadFile(cfg.Auth.JWTSecretFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read jwt secret file: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Auth.jwtSecret = strings.TrimSpace(string(secretData))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port DB par défaut
|
||||||
|
if cfg.Database.Port == 0 {
|
||||||
|
cfg.Database.Port = 3306
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiry JWT par défaut
|
||||||
|
if cfg.Auth.JWTExpiry == "" {
|
||||||
|
cfg.Auth.JWTExpiry = "24h"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByApp retourne la configuration d'une application par son ID.
|
||||||
|
func (r *Registry) GetByApp(appID string) (*AppConfig, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
cfg, ok := r.apps[appID]
|
||||||
|
return cfg, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByHost retourne la configuration d'une application par son hostname.
|
||||||
|
func (r *Registry) GetByHost(host string) (*AppConfig, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
cfg, ok := r.byHost[host]
|
||||||
|
return cfg, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apps retourne la liste des IDs d'applications chargées.
|
||||||
|
func (r *Registry) Apps() []string {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
apps := make([]string, 0, len(r.apps))
|
||||||
|
for app := range r.apps {
|
||||||
|
apps = append(apps, app)
|
||||||
|
}
|
||||||
|
return apps
|
||||||
|
}
|
||||||
169
internal/protocol/client.go
Normal file
169
internal/protocol/client.go
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package protocol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client permet d'appeler un microservice via Unix socket.
|
||||||
|
type Client struct {
|
||||||
|
socketPath string
|
||||||
|
conn net.Conn
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient crée un nouveau client.
|
||||||
|
func NewClient(socketPath string) *Client {
|
||||||
|
return &Client{
|
||||||
|
socketPath: socketPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect établit la connexion au socket.
|
||||||
|
func (c *Client) Connect() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil // Déjà connecté
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := net.Dial("unix", c.socketPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dial %s: %w", c.socketPath, err)
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close ferme la connexion.
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.conn != nil {
|
||||||
|
err := c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call envoie une requête et attend la réponse.
|
||||||
|
func (c *Client) Call(ctx context.Context, req *Request) (*Response, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.conn == nil {
|
||||||
|
return nil, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout
|
||||||
|
timeout := time.Duration(req.TimeoutMs) * time.Millisecond
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
c.conn.SetDeadline(deadline)
|
||||||
|
|
||||||
|
// Encoder et envoyer la requête
|
||||||
|
data, err := Encode(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeMessage(c.conn, data); err != nil {
|
||||||
|
c.conn = nil // Connexion cassée
|
||||||
|
return nil, fmt.Errorf("write request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lire la réponse
|
||||||
|
respData, err := readMessage(c.conn)
|
||||||
|
if err != nil {
|
||||||
|
c.conn = nil // Connexion cassée
|
||||||
|
return nil, fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := DecodeResponse(respData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallAction raccourci pour appeler une action.
|
||||||
|
func (c *Client) CallAction(ctx context.Context, action string, params map[string]any) (*Response, error) {
|
||||||
|
req := NewRequest(action, params)
|
||||||
|
return c.Call(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pool gère un pool de connexions vers un service.
|
||||||
|
type Pool struct {
|
||||||
|
socketPath string
|
||||||
|
clients chan *Client
|
||||||
|
maxSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPool crée un pool de connexions.
|
||||||
|
func NewPool(socketPath string, size int) *Pool {
|
||||||
|
return &Pool{
|
||||||
|
socketPath: socketPath,
|
||||||
|
clients: make(chan *Client, size),
|
||||||
|
maxSize: size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get obtient un client du pool.
|
||||||
|
func (p *Pool) Get() (*Client, error) {
|
||||||
|
select {
|
||||||
|
case client := <-p.clients:
|
||||||
|
return client, nil
|
||||||
|
default:
|
||||||
|
// Créer un nouveau client
|
||||||
|
client := NewClient(p.socketPath)
|
||||||
|
if err := client.Connect(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put remet un client dans le pool.
|
||||||
|
func (p *Pool) Put(client *Client) {
|
||||||
|
select {
|
||||||
|
case p.clients <- client:
|
||||||
|
// OK, remis dans le pool
|
||||||
|
default:
|
||||||
|
// Pool plein, fermer le client
|
||||||
|
client.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call obtient un client, exécute l'appel, et remet le client.
|
||||||
|
func (p *Pool) Call(ctx context.Context, req *Request) (*Response, error) {
|
||||||
|
client, err := p.Get()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Call(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
client.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Put(client)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close ferme tous les clients du pool.
|
||||||
|
func (p *Pool) Close() {
|
||||||
|
close(p.clients)
|
||||||
|
for client := range p.clients {
|
||||||
|
client.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
90
internal/protocol/message.go
Normal file
90
internal/protocol/message.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// Package protocol définit le protocole de communication IPC via Unix sockets.
|
||||||
|
// Format: 4 bytes (big-endian length) + JSON payload
|
||||||
|
package protocol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request représente une requête envoyée à un microservice.
|
||||||
|
type Request struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
TenantID string `json:"tenant_id,omitempty"`
|
||||||
|
Params map[string]any `json:"params,omitempty"`
|
||||||
|
TimeoutMs int `json:"timeout_ms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response représente la réponse d'un microservice.
|
||||||
|
type Response struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"` // "success" ou "error"
|
||||||
|
Result any `json:"result,omitempty"`
|
||||||
|
Error *Error `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error détaille une erreur.
|
||||||
|
type Error struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRequest crée une nouvelle requête avec un ID unique.
|
||||||
|
func NewRequest(action string, params map[string]any) *Request {
|
||||||
|
return &Request{
|
||||||
|
ID: generateID(),
|
||||||
|
Action: action,
|
||||||
|
Params: params,
|
||||||
|
TimeoutMs: 5000, // 5s par défaut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success crée une réponse de succès.
|
||||||
|
func Success(reqID string, result any) *Response {
|
||||||
|
return &Response{
|
||||||
|
ID: reqID,
|
||||||
|
Status: "success",
|
||||||
|
Result: result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure crée une réponse d'erreur.
|
||||||
|
func Failure(reqID string, code, message string) *Response {
|
||||||
|
return &Response{
|
||||||
|
ID: reqID,
|
||||||
|
Status: "error",
|
||||||
|
Error: &Error{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode sérialise un message en JSON.
|
||||||
|
func Encode(v any) ([]byte, error) {
|
||||||
|
return json.Marshal(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeRequest désérialise une requête JSON.
|
||||||
|
func DecodeRequest(data []byte) (*Request, error) {
|
||||||
|
var req Request
|
||||||
|
if err := json.Unmarshal(data, &req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeResponse désérialise une réponse JSON.
|
||||||
|
func DecodeResponse(data []byte) (*Response, error) {
|
||||||
|
var resp Response
|
||||||
|
if err := json.Unmarshal(data, &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateID génère un ID unique basé sur le timestamp.
|
||||||
|
func generateID() string {
|
||||||
|
return "req_" + time.Now().Format("20060102150405.000000")
|
||||||
|
}
|
||||||
174
internal/protocol/server.go
Normal file
174
internal/protocol/server.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package protocol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler traite une requête et retourne une réponse.
|
||||||
|
type Handler func(ctx context.Context, req *Request) *Response
|
||||||
|
|
||||||
|
// Server écoute sur un Unix socket et dispatch les requêtes.
|
||||||
|
type Server struct {
|
||||||
|
socketPath string
|
||||||
|
handler Handler
|
||||||
|
listener net.Listener
|
||||||
|
wg sync.WaitGroup
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer crée un nouveau serveur.
|
||||||
|
func NewServer(socketPath string, handler Handler) *Server {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
return &Server{
|
||||||
|
socketPath: socketPath,
|
||||||
|
handler: handler,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start démarre le serveur.
|
||||||
|
func (s *Server) Start() error {
|
||||||
|
// Supprimer le socket existant
|
||||||
|
if err := os.RemoveAll(s.socketPath); err != nil {
|
||||||
|
return fmt.Errorf("remove socket: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("unix", s.socketPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listen: %w", err)
|
||||||
|
}
|
||||||
|
s.listener = listener
|
||||||
|
|
||||||
|
// Permissions socket
|
||||||
|
if err := os.Chmod(s.socketPath, 0660); err != nil {
|
||||||
|
return fmt.Errorf("chmod socket: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[server] listening on %s", s.socketPath)
|
||||||
|
|
||||||
|
go s.acceptLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop arrête le serveur proprement.
|
||||||
|
func (s *Server) Stop() {
|
||||||
|
s.cancel()
|
||||||
|
if s.listener != nil {
|
||||||
|
s.listener.Close()
|
||||||
|
}
|
||||||
|
s.wg.Wait()
|
||||||
|
os.RemoveAll(s.socketPath)
|
||||||
|
log.Printf("[server] stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// acceptLoop accepte les connexions entrantes.
|
||||||
|
func (s *Server) acceptLoop() {
|
||||||
|
for {
|
||||||
|
conn, err := s.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
select {
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
log.Printf("[server] accept error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.wg.Add(1)
|
||||||
|
go s.handleConn(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConn gère une connexion client.
|
||||||
|
func (s *Server) handleConn(conn net.Conn) {
|
||||||
|
defer s.wg.Done()
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lire la requête
|
||||||
|
data, err := readMessage(conn)
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
log.Printf("[server] read error: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Décoder la requête
|
||||||
|
req, err := DecodeRequest(data)
|
||||||
|
if err != nil {
|
||||||
|
resp := Failure("", "DECODE_ERROR", err.Error())
|
||||||
|
writeResponse(conn, resp)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traiter la requête
|
||||||
|
resp := s.handler(s.ctx, req)
|
||||||
|
|
||||||
|
// Envoyer la réponse
|
||||||
|
if err := writeResponse(conn, resp); err != nil {
|
||||||
|
log.Printf("[server] write error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMessage lit un message length-prefixed.
|
||||||
|
func readMessage(r io.Reader) ([]byte, error) {
|
||||||
|
// Lire les 4 bytes de longueur
|
||||||
|
lengthBuf := make([]byte, 4)
|
||||||
|
if _, err := io.ReadFull(r, lengthBuf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
length := binary.BigEndian.Uint32(lengthBuf)
|
||||||
|
if length == 0 || length > 10*1024*1024 { // Max 10MB
|
||||||
|
return nil, fmt.Errorf("invalid message length: %d", length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lire le payload
|
||||||
|
data := make([]byte, length)
|
||||||
|
if _, err := io.ReadFull(r, data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeResponse écrit une réponse.
|
||||||
|
func writeResponse(w io.Writer, resp *Response) error {
|
||||||
|
data, err := Encode(resp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeMessage(w, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMessage écrit un message length-prefixed.
|
||||||
|
func writeMessage(w io.Writer, data []byte) error {
|
||||||
|
// Écrire la longueur
|
||||||
|
lengthBuf := make([]byte, 4)
|
||||||
|
binary.BigEndian.PutUint32(lengthBuf, uint32(len(data)))
|
||||||
|
if _, err := w.Write(lengthBuf); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Écrire le payload
|
||||||
|
_, err := w.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
8
internal/version/version.go
Normal file
8
internal/version/version.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Package version contient les informations de version.
|
||||||
|
package version
|
||||||
|
|
||||||
|
// Set via ldflags: -ldflags "-X sogoms.com/internal/version.Version=1.0.0"
|
||||||
|
var (
|
||||||
|
Version = "dev"
|
||||||
|
BuildTime = "unknown"
|
||||||
|
)
|
||||||
0
prompts.txt
Normal file
0
prompts.txt
Normal file
1213
sogoms-vigil.md
Executable file
1213
sogoms-vigil.md
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user