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:
2025-12-15 19:09:00 +01:00
commit 7e27f87d6f
64 changed files with 7951 additions and 0 deletions

View 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'],
]);
}
}
}

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

View 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);
}
}
}

View 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();
}
}

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