diff --git a/TASKS_MODULE_ROADMAP.md b/TASKS_MODULE_ROADMAP.md new file mode 100644 index 0000000..424d80a --- /dev/null +++ b/TASKS_MODULE_ROADMAP.md @@ -0,0 +1,453 @@ +# План доработки модуля Tasks + +> На основе ТЗ п.3.6 и анализа текущего состояния кода + +--- + +## Этап 1: Базовые улучшения (Critical) + +### 1.1 RBAC — добавить проверку прав + +**Файлы:** `TasksController.php` + +```php +// Добавить в начало методов: +public function store() +{ + if (!$this->access->canCreate('tasks')) { + return $this->forbiddenResponse('Нет прав для создания задач'); + } +} + +public function edit(int $id) +{ + if (!$this->access->canEdit('tasks')) { + return $this->forbiddenResponse('Нет прав для редактирования'); + } +} +``` + +**Аналогично:** `update()`, `destroy()`, `moveColumn()`, `complete()`, `reopen()` + +### 1.2 Валидация входных данных + +**Файлы:** `TasksController.php` + +```php +public function store() +{ + $validation = \Config\Services::validation(); + $validation->setRules([ + 'title' => 'required|min_length[3]|max_length[255]', + 'board_id' => 'required|integer', + 'column_id' => 'required|integer', + 'priority' => 'in_list[low,medium,high,urgent]', + 'due_date' => 'valid_date', + ]); + + if (!$validation->withRequest($this->request)->run()) { + return redirect()->back()->withErrors($validation->getErrors())->withInput(); + } +} +``` + +### 1.3 Исправить Events нейминг + +**Файл:** `TaskService.php` + +```php +// Было: +Events::trigger('tasks.created', $taskId, $data, $userId); + +// Стало (согласно ТЗ и документации): +Events::trigger('task.created', $taskId, $data, $userId); +``` + +**Аналогично:** `task.updated`, `task.column_changed`, `task.completed`, `task.reopened` + +--- + +## Этап 2: Подзадачи (Subtasks) + +### 2.1 Миграция + +```php +// database/migrations/2026-02-08-CreateTaskSubtasksTable.php +public function up() +{ + $this->forge->addField([ + 'id' => ['type' => 'INT', 'auto_increment' => true], + 'task_id' => ['type' => 'INT'], + 'title' => ['type' => 'VARCHAR', 'constraint' => 255], + 'is_completed' => ['type' => 'BOOLEAN', 'default' => false], + 'order_index' => ['type' => 'INT', 'default' => 0], + 'created_at' => ['type' => 'DATETIME'], + 'updated_at' => ['type' => 'DATETIME'], + ]); + $this->forge->addKey('id', true); + $this->forge->addForeignKey('task_id', 'tasks', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('task_subtasks'); +} +``` + +### 2.2 Модель + +```php +// app/Modules/Tasks/Models/TaskSubtaskModel.php +class TaskSubtaskModel extends Model +{ + use TenantScopedModel; + protected $table = 'task_subtasks'; + protected $tenantField = 'task.organization_id'; // через task relationship + + protected $allowedFields = ['task_id', 'title', 'is_completed', 'order_index']; +} +``` + +### 2.3 API + +```php +// TasksController.php +public function addSubtask(int $taskId) +{ + $data = [ + 'task_id' => $taskId, + 'title' => $this->request->getPost('title'), + 'order_index' => $this->subtaskModel->getNextOrder($taskId), + ]; + $this->subtaskModel->insert($data); + return redirect()->to("/tasks/{$taskId}"); +} + +public function toggleSubtask(int $taskId, int $subtaskId) +{ + $subtask = $this->subtaskModel->find($subtaskId); + $this->subtaskModel->update($subtaskId, [ + 'is_completed' => !$subtask['is_completed'] + ]); +} +``` + +### 2.4 View + +```twig +{# tasks/show.twig #} +
+ {% for subtask in task.subtasks %} +
+ + {{ subtask.title }} +
+ {% endfor %} + +
+``` + +--- + +## Этап 3: Чек-листы (Checklists) + +> Аналогично подзадачам, но чек-листы — это произвольные списки внутри задачи + +### 3.1 Структура + +``` +task_checklists +├── id +├── task_id +├── title (например "Подготовка к встрече") +└── items (JSON array или связанная таблица) + +task_checklist_items +├── id +├── checklist_id +├── text +├── is_completed +└── order_index +``` + +--- + +## Этап 4: Вложения (Attachments) + +### 4.1 Миграция + +```php +// database/migrations/2026-02-08-CreateTaskAttachmentsTable.php +public function up() +{ + $this->forge->addField([ + 'id' => ['type' => 'INT', 'auto_increment' => true], + 'task_id' => ['type' => 'INT'], + 'file_name' => ['type' => 'VARCHAR', 'constraint' => 255], + 'file_path' => ['type' => 'VARCHAR', 'constraint' => 500], + 'file_size' => ['type' => 'INT'], + 'file_type' => ['type' => 'VARCHAR', 'constraint' => 100], + 'uploaded_by' => ['type' => 'INT'], + 'created_at' => ['type' => 'DATETIME'], + ]); + $this->forge->addKey('id', true); + $this->forge->addForeignKey('task_id', 'tasks', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('task_attachments'); +} +``` + +### 4.2 Загрузка файлов + +```php +// TasksController.php +public function uploadAttachment(int $taskId) +{ + $file = $this->request->getFile('file'); + if ($file->isValid()) { + $uploadedPath = $this->uploadService->upload($file, "tasks/{$taskId}"); + $this->attachmentModel->insert([ + 'task_id' => $taskId, + 'file_name' => $file->getName(), + 'file_path' => $uploadedPath, + 'file_size' => $file->getSize(), + 'file_type' => $file->getMimeType(), + 'uploaded_by' => $this->getCurrentUserId(), + ]); + } +} +``` + +--- + +## Этап 5: Комментарии + +### 5.1 Миграция + +```php +// database/migrations/2026-02-08-CreateTaskCommentsTable.php +public function up() +{ + $this->forge->addField([ + 'id' => ['type' => 'INT', 'auto_increment' => true], + 'task_id' => ['type' => 'INT'], + 'user_id' => ['type' => 'INT'], + 'content' => ['type' => 'TEXT'], + 'mentioned_users' => ['type' => 'JSON'], // массив user_id для @mentions + 'created_at' => ['type' => 'DATETIME'], + 'updated_at' => ['type' => 'DATETIME'], + ]); + $this->forge->addKey('id', true); + $this->forge->addForeignKey('task_id', 'tasks', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('task_comments'); +} +``` + +### 5.2 Обработка @mentions + +```php +// TaskCommentService.php +public function parseMentions(string $content): array +{ + preg_match_all('/@(\d+)/', $content, $matches); + return $matches[1] ?? []; +} + +public function createComment(int $taskId, int $userId, string $content): int +{ + $mentionedUsers = $this->parseMentions($content); + + $commentId = $this->commentModel->insert([ + 'task_id' => $taskId, + 'user_id' => $userId, + 'content' => $content, + 'mentioned_users' => json_encode($mentionedUsers), + ]); + + // Уведомить упомянутых пользователей + foreach ($mentionedUsers as $mentionedUserId) { + $this->notificationService->create([ + 'user_id' => $mentionedUserId, + 'type' => 'mention', + 'message' => "Вас упомянули в задаче", + 'link' => "/tasks/{$taskId}", + ]); + } + + return $commentId; +} +``` + +--- + +## Этап 6: Зависимости задач + +### 6.1 Миграция + +```php +// database/migrations/2026-02-08-CreateTaskDependenciesTable.php +public function up() +{ + $this->forge->addField([ + 'id' => ['type' => 'INT', 'auto_increment' => true], + 'blocking_task_id' => ['type' => 'INT'], // задача, которая блокирует + 'blocked_task_id' => ['type' => 'INT'], // задача, которая заблокирована + 'dependency_type' => ['type' => 'ENUM', 'options' => ['blocks', 'depends_on']], + 'created_at' => ['type' => 'DATETIME'], + ]); + $this->forge->addKey('id', true); + $this->forge->addForeignKey('blocking_task_id', 'tasks', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('blocked_task_id', 'tasks', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('task_dependencies'); +} +``` + +### 6.2 Проверка при изменении статуса + +```php +// TaskService.php +public function completeTask(int $taskId, int $userId): bool +{ + // Проверить зависимости + $blockingTasks = $this->getBlockingTasks($taskId); + if (!empty($blockingTasks)) { + foreach ($blockingTasks as $blocking) { + if (!$blocking['is_completed']) { + throw new \Exception("Задача заблокирована: {$blocking['title']}"); + } + } + } + + return parent::completeTask($taskId, $userId); +} +``` + +--- + +## Этап 7: Интеграция с CRM + +### 7.1 События CRM → Tasks + +```php +// app/Config/Events.php + +// При создании сделки +Events::on('deal.created', function ($deal, $userId) { + service('eventManager') + ->forModule('crm') + ->moduleOn('deal.created', function ($deal, $userId) { + if (service('moduleSubscription')->isModuleActive('tasks')) { + $taskService = service('taskService'); + $taskService->createTask([ + 'title' => "Первичный контакт: {$deal['title']}", + 'description' => 'Сделка создана, необходимо связаться с клиентом', + 'priority' => 'high', + 'due_date' => date('Y-m-d', strtotime('+1 day')), + ], $userId); + } + }); +}); + +// При переводе на этап "Коммерческое предложение" +Events::on('deal.stage_changed', function ($deal, $oldStage, $newStage, $userId) { + if ($newStage === 'proposal') { + // Создать задачу на подготовку КП + } +}); + +// При завершении сделки +Events::on('deal.won', function ($deal, $userId) { + // Создать задачу "Благодарность клиенту" +}); +``` + +### 7.2 Задачи в карточке клиента + +```php +// CRM/Controllers/ClientsController.php +public function view(int $id) +{ + $client = $this->clientModel->find($id); + $tasks = $this->taskService->getByClientId($id); // метод для получения задач клиента + + return $this->renderTwig('@CRM/Clients/view', [ + 'client' => $client, + 'tasks' => $tasks, + ]); +} +``` + +--- + +## Этап 8: Интеграция с Booking + +### 8.1 События Booking → Tasks + +```php +// При создании записи +Events::on('booking.created', function ($booking, $userId) { + // Создать задачу на подготовку к встрече +}); +``` + +--- + +## Приоритеты реализации + +| Приоритет | Задача | Оценка | +|-----------|--------|--------| +| P0 | RBAC (права доступа) | 2ч | +| P0 | Валидация | 2ч | +| P1 | Подзадачи (subtasks) | 8ч | +| P1 | Чек-листы | 6ч | +| P1 | Вложения | 8ч | +| P2 | Комментарии + @mentions | 12ч | +| P2 | Зависимости задач | 6ч | +| P2 | Интеграция CRM → Tasks | 8ч | +| P3 | Интеграция Booking → Tasks | 4ч | + +**Общая оценка:** ~56 часов + +--- + +## Зависимости между этапами + +``` +Этап 1 (RBAC + валидация) → нужен перед всеми + +Этап 2 (subtasks) → зависит от 1 +Этап 3 (checklists) → зависит от 1 +Этап 4 (attachments) → зависит от 1 + +Этап 5 (comments) → зависит от 2 +Этап 6 (dependencies) → зависит от 1 + +Этап 7 (CRM integration) → зависит от 5 +Этап 8 (Booking integration) → зависит от 1 +``` + +--- + +## Модели для создания/обновления + +``` +app/Modules/Tasks/Models/ +├── TaskModel.php (существует) +├── TaskColumnModel.php (существует) +├── TaskBoardModel.php (существует) +├── TaskAssigneeModel.php (существует) +├── TaskSubtaskModel.php ✨ НОВАЯ +├── TaskChecklistModel.php ✨ НОВАЯ +├── TaskChecklistItemModel.php ✨ НОВАЯ +├── TaskAttachmentModel.php ✨ НОВАЯ +└── TaskCommentModel.php ✨ НОВАЯ +``` + +--- + +## Контроллеры + +``` +app/Modules/Tasks/Controllers/ +├── TasksController.php (существует) +├── TaskApiController.php (существует) +├── TaskSubtasksController.php ✨ НОВЫЙ +├── TaskAttachmentsController.php ✨ НОВЫЙ +└── TaskCommentsController.php ✨ НОВЫЙ +``` diff --git a/app/Modules/Tasks/Controllers/TasksController.php b/app/Modules/Tasks/Controllers/TasksController.php index 3ed643d..ab6947b 100644 --- a/app/Modules/Tasks/Controllers/TasksController.php +++ b/app/Modules/Tasks/Controllers/TasksController.php @@ -216,6 +216,10 @@ class TasksController extends BaseController */ public function create() { + if (!$this->access->canCreate('tasks')) { + return $this->forbiddenResponse('Нет прав для создания задач'); + } + $organizationId = $this->requireActiveOrg(); $boardId = (int) ($this->request->getGet('board') ?? 0); @@ -263,17 +267,35 @@ class TasksController extends BaseController */ public function store() { + if (!$this->access->canCreate('tasks')) { + return $this->forbiddenResponse('Нет прав для создания задач'); + } + $organizationId = $this->requireActiveOrg(); $userId = $this->getCurrentUserId(); + // Валидация входных данных + $validation = \Config\Services::validation(); + $validation->setRules([ + 'title' => 'required|min_length[3]|max_length[255]', + 'board_id' => 'required|integer', + 'column_id' => 'required|integer', + 'priority' => 'in_list[low,medium,high,urgent]', + 'due_date' => 'permit_empty|valid_date[Y-m-d]', + ]); + + if (!$validation->withRequest($this->request)->run()) { + return redirect()->back()->withErrors($validation->getErrors())->withInput(); + } + $data = [ 'organization_id' => $organizationId, - 'board_id' => $this->request->getPost('board_id'), - 'column_id' => $this->request->getPost('column_id'), - 'title' => $this->request->getPost('title'), - 'description' => $this->request->getPost('description'), - 'priority' => $this->request->getPost('priority') ?? 'medium', - 'due_date' => $this->request->getPost('due_date') ?: null, + 'board_id' => (int) $this->request->getPost('board_id'), + 'column_id' => (int) $this->request->getPost('column_id'), + 'title' => trim($this->request->getPost('title')), + 'description' => trim($this->request->getPost('description')) ?: null, + 'priority' => $this->request->getPost('priority') ?? 'medium', + 'due_date' => $this->request->getPost('due_date') ?: null, ]; $taskId = $this->taskService->createTask($data, $userId); @@ -314,6 +336,10 @@ class TasksController extends BaseController */ public function edit(int $id) { + if (!$this->access->canEdit('tasks')) { + return $this->forbiddenResponse('Нет прав для редактирования задач'); + } + $organizationId = $this->requireActiveOrg(); $task = $this->taskService->getTask($id, $organizationId); @@ -353,14 +379,32 @@ class TasksController extends BaseController */ public function update(int $id) { + if (!$this->access->canEdit('tasks')) { + return $this->forbiddenResponse('Нет прав для редактирования задач'); + } + $organizationId = $this->requireActiveOrg(); $userId = $this->getCurrentUserId(); + // Валидация входных данных + $validation = \Config\Services::validation(); + $validation->setRules([ + 'title' => 'required|min_length[3]|max_length[255]', + 'board_id' => 'required|integer', + 'column_id' => 'required|integer', + 'priority' => 'in_list[low,medium,high,urgent]', + 'due_date' => 'permit_empty|valid_date[Y-m-d]', + ]); + + if (!$validation->withRequest($this->request)->run()) { + return redirect()->back()->withErrors($validation->getErrors())->withInput(); + } + $data = [ - 'board_id' => $this->request->getPost('board_id'), - 'column_id' => $this->request->getPost('column_id'), - 'title' => $this->request->getPost('title'), - 'description' => $this->request->getPost('description'), + 'board_id' => (int) $this->request->getPost('board_id'), + 'column_id' => (int) $this->request->getPost('column_id'), + 'title' => trim($this->request->getPost('title')), + 'description' => trim($this->request->getPost('description')) ?: null, 'priority' => $this->request->getPost('priority') ?? 'medium', 'due_date' => $this->request->getPost('due_date') ?: null, ]; @@ -383,6 +427,10 @@ class TasksController extends BaseController */ public function destroy(int $id) { + if (!$this->access->canDelete('tasks')) { + return $this->forbiddenResponse('Нет прав для удаления задач'); + } + $organizationId = $this->requireActiveOrg(); $userId = $this->getCurrentUserId(); @@ -396,11 +444,25 @@ class TasksController extends BaseController */ public function moveColumn() { + if (!$this->access->canEdit('tasks')) { + return $this->response->setJSON([ + 'success' => false, + 'error' => 'Нет прав для изменения задач' + ]); + } + $organizationId = $this->requireActiveOrg(); $userId = $this->getCurrentUserId(); - $taskId = $this->request->getPost('task_id'); - $newColumnId = $this->request->getPost('column_id'); + $taskId = (int) $this->request->getPost('task_id'); + $newColumnId = (int) $this->request->getPost('column_id'); + + if (!$taskId || !$newColumnId) { + return $this->response->setJSON([ + 'success' => false, + 'error' => 'Некорректные параметры' + ]); + } $result = $this->taskService->changeColumn($taskId, $newColumnId, $userId); @@ -418,6 +480,13 @@ class TasksController extends BaseController */ public function complete(int $id) { + if (!$this->access->canEdit('tasks')) { + return $this->response->setJSON([ + 'success' => false, + 'error' => 'Нет прав для изменения задач' + ]); + } + $userId = $this->getCurrentUserId(); $result = $this->taskService->completeTask($id, $userId); @@ -429,6 +498,13 @@ class TasksController extends BaseController */ public function reopen(int $id) { + if (!$this->access->canEdit('tasks')) { + return $this->response->setJSON([ + 'success' => false, + 'error' => 'Нет прав для изменения задач' + ]); + } + $userId = $this->getCurrentUserId(); $result = $this->taskService->reopenTask($id, $userId); diff --git a/app/Modules/Tasks/Services/TaskService.php b/app/Modules/Tasks/Services/TaskService.php index 0005394..d9949bd 100644 --- a/app/Modules/Tasks/Services/TaskService.php +++ b/app/Modules/Tasks/Services/TaskService.php @@ -44,7 +44,7 @@ class TaskService } // Генерируем событие - Events::trigger('tasks.created', $taskId, $data, $userId); + Events::trigger('task.created', $taskId, $data, $userId); } return $taskId; @@ -63,7 +63,7 @@ class TaskService $result = $this->taskModel->update($taskId, $data); if ($result) { - Events::trigger('tasks.updated', $taskId, $data, $userId); + Events::trigger('task.updated', $taskId, $data, $userId); } return $result; @@ -96,7 +96,7 @@ class TaskService $result = $this->taskModel->update($taskId, $data); if ($result) { - Events::trigger('tasks.column_changed', $taskId, $oldColumnId, $newColumnId, $userId); + Events::trigger('task.column_changed', $taskId, $oldColumnId, $newColumnId, $userId); } return $result; @@ -117,7 +117,7 @@ class TaskService ]); if ($result) { - Events::trigger('tasks.completed', $taskId, $userId); + Events::trigger('task.completed', $taskId, $userId); } return $result; @@ -138,7 +138,7 @@ class TaskService ]); if ($result) { - Events::trigger('tasks.reopened', $taskId, $userId); + Events::trigger('task.reopened', $taskId, $userId); } return $result; @@ -157,7 +157,7 @@ class TaskService $result = $this->taskModel->delete($taskId); if ($result) { - Events::trigger('tasks.deleted', $taskId, $userId); + Events::trigger('task.deleted', $taskId, $userId); } return $result;