SOGOMS v1.0.1 - Microservices logs, smtp et roadmap
Nouveaux services:
- sogoms-logs : logging centralisé avec rotation
- sogoms-smtp : envoi emails avec templates YAML
Nouvelles fonctionnalités:
- Queries YAML externalisées (config/queries/{app}/)
- CRUD générique paramétrable
- Filtres par rôle (default, admin)
- Templates email (config/emails/{app}/)
Documentation:
- DOCTECH.md : documentation technique complète
- README.md : vision et roadmap
- TODO.md : phases 11-15 planifiées
Roadmap:
- Phase 11: sogoms-crypt (chiffrement)
- Phase 12: sogoms-imap/mailproc (emails)
- Phase 13: sogoms-cron (tâches planifiées)
- Phase 14: sogoms-push (MQTT temps réel)
- Phase 15: sogoms-schema (API auto-générée)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
202
clients/prokov.sql
Normal file
202
clients/prokov.sql
Normal file
@@ -0,0 +1,202 @@
|
||||
/*M!999999\- enable the sandbox mode */
|
||||
-- MariaDB dump 10.19-11.8.3-MariaDB, for debian-linux-gnu (x86_64)
|
||||
--
|
||||
-- Host: localhost Database: prokov
|
||||
-- ------------------------------------------------------
|
||||
-- Server version 11.4.8-MariaDB-log
|
||||
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
|
||||
/*!40101 SET NAMES utf8mb4 */;
|
||||
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
|
||||
/*!40103 SET TIME_ZONE='+00:00' */;
|
||||
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
|
||||
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||
/*M!100616 SET @OLD_NOTE_VERBOSITY=@@NOTE_VERBOSITY, NOTE_VERBOSITY=0 */;
|
||||
|
||||
--
|
||||
-- Table structure for table `project_tags`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `project_tags`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `project_tags` (
|
||||
`project_id` int(10) unsigned NOT NULL,
|
||||
`tag_id` int(10) unsigned NOT NULL,
|
||||
PRIMARY KEY (`project_id`,`tag_id`),
|
||||
KEY `tag_id` (`tag_id`),
|
||||
CONSTRAINT `project_tags_ibfk_1` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `project_tags_ibfk_2` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `projects`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `projects`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `projects` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(10) unsigned NOT NULL,
|
||||
`parent_id` int(10) unsigned DEFAULT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`description` text DEFAULT NULL,
|
||||
`position` int(10) unsigned DEFAULT 0,
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `parent_id` (`parent_id`),
|
||||
KEY `idx_projects_parent` (`user_id`,`parent_id`),
|
||||
CONSTRAINT `projects_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `projects_ibfk_2` FOREIGN KEY (`parent_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `statuses`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `statuses`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `statuses` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(10) unsigned NOT NULL,
|
||||
`project_id` int(10) unsigned DEFAULT NULL,
|
||||
`code` int(10) unsigned NOT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`color` varchar(7) DEFAULT '#6B7280',
|
||||
`position` int(10) unsigned DEFAULT 0,
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_statuses_user_project` (`user_id`,`project_id`),
|
||||
CONSTRAINT `statuses_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `tags`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `tags`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `tags` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(10) unsigned NOT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`color` varchar(7) DEFAULT '#3B82F6',
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `unique_tag_per_user` (`user_id`,`name`),
|
||||
CONSTRAINT `tags_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `task_tags`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `task_tags`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `task_tags` (
|
||||
`task_id` int(10) unsigned NOT NULL,
|
||||
`tag_id` int(10) unsigned NOT NULL,
|
||||
PRIMARY KEY (`task_id`,`tag_id`),
|
||||
KEY `tag_id` (`tag_id`),
|
||||
CONSTRAINT `task_tags_ibfk_1` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `task_tags_ibfk_2` FOREIGN KEY (`tag_id`) REFERENCES `tags` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `tasks`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `tasks`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `tasks` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`user_id` int(10) unsigned NOT NULL,
|
||||
`project_id` int(10) unsigned NOT NULL,
|
||||
`status_id` int(10) unsigned NOT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`description` text DEFAULT NULL,
|
||||
`priority` tinyint(3) unsigned DEFAULT 5,
|
||||
`date_start` date DEFAULT NULL,
|
||||
`date_end` date DEFAULT NULL,
|
||||
`time_estimated` int(10) unsigned DEFAULT 0,
|
||||
`time_spent` int(10) unsigned DEFAULT 0,
|
||||
`billing` decimal(10,2) DEFAULT 0.00,
|
||||
`position` int(10) unsigned DEFAULT 0,
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `status_id` (`status_id`),
|
||||
KEY `idx_tasks_project_status` (`project_id`,`status_id`),
|
||||
KEY `idx_tasks_user_status` (`user_id`,`status_id`),
|
||||
KEY `idx_tasks_dates` (`date_start`,`date_end`),
|
||||
CONSTRAINT `tasks_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `tasks_ibfk_2` FOREIGN KEY (`project_id`) REFERENCES `projects` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `tasks_ibfk_3` FOREIGN KEY (`status_id`) REFERENCES `statuses` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `users`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `users`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `users` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`email` varchar(255) NOT NULL,
|
||||
`password` varchar(255) NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`role_id` int(10) unsigned NOT NULL DEFAULT 1,
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
`updated_at` timestamp NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `email` (`email`),
|
||||
KEY `role_id` (`role_id`),
|
||||
CONSTRAINT `users_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `users_roles` (`id`)
|
||||
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Table structure for table `users_roles`
|
||||
--
|
||||
|
||||
DROP TABLE IF EXISTS `users_roles`;
|
||||
/*!40101 SET @saved_cs_client = @@character_set_client */;
|
||||
/*!40101 SET character_set_client = utf8mb4 */;
|
||||
CREATE TABLE `users_roles` (
|
||||
`id` int(10) unsigned NOT NULL,
|
||||
`name` varchar(50) NOT NULL,
|
||||
`created_at` timestamp NULL DEFAULT current_timestamp(),
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
/*!40101 SET character_set_client = @saved_cs_client */;
|
||||
|
||||
--
|
||||
-- Dumping routines for database 'prokov'
|
||||
--
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
|
||||
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
|
||||
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
|
||||
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
|
||||
/*M!100616 SET NOTE_VERBOSITY=@OLD_NOTE_VERBOSITY */;
|
||||
|
||||
-- Dump completed on 2025-12-16 10:58:46
|
||||
@@ -1,39 +0,0 @@
|
||||
<?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() {}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
<?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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,74 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?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();
|
||||
Reference in New Issue
Block a user