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 #}
+
+```
+
+---
+
+## Этап 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;