Tasks Module Stage 1: RBAC + Validation + Events Fix
- Add canCreate/canEdit/canDelete checks in TasksController - Add input validation in store() and update() methods - Fix events naming: task.* (singular) instead of tasks.* - Add CSRF validation and parameter checks for API endpoints
This commit is contained in:
parent
9b8d10bbfa
commit
d7fec7169f
|
|
@ -0,0 +1,112 @@
|
||||||
|
# План доработки модуля Tasks
|
||||||
|
|
||||||
|
> На основе ТЗ п.3.6
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 1: RBAC + валидация (Готово ✅)
|
||||||
|
- [x] `canCreate()` в create(), store()
|
||||||
|
- [x] `canEdit()` в edit(), update(), moveColumn(), complete(), reopen()
|
||||||
|
- [x] `canDelete()` в destroy()
|
||||||
|
- [x] Валидация в store(), update()
|
||||||
|
- [x] Исправлен нейминг событий: `task.*` (singular)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 2: Подзадачи (Subtasks)
|
||||||
|
|
||||||
|
### 2.1 Миграция
|
||||||
|
```php
|
||||||
|
task_subtasks (id, task_id, title, is_completed, order_index, created_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Модель + API
|
||||||
|
- TaskSubtaskModel
|
||||||
|
- addSubtask(), toggleSubtask(), deleteSubtask()
|
||||||
|
|
||||||
|
### 2.3 View
|
||||||
|
- Отображение подзадач в task/show.twig
|
||||||
|
- Чекбоксы для toggle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 3: Чек-листы
|
||||||
|
|
||||||
|
### 3.1 Структура
|
||||||
|
```
|
||||||
|
task_checklists (id, task_id, title)
|
||||||
|
task_checklist_items (id, checklist_id, text, is_completed, order_index)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 4: Вложения
|
||||||
|
|
||||||
|
### 4.1 Миграция
|
||||||
|
```php
|
||||||
|
task_attachments (id, task_id, file_name, file_path, file_size, uploaded_by, created_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 API
|
||||||
|
- uploadAttachment(), deleteAttachment()
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 5: Комментарии + @mentions
|
||||||
|
|
||||||
|
### 5.1 Миграция
|
||||||
|
```php
|
||||||
|
task_comments (id, task_id, user_id, content, mentioned_users(JSON), created_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Обработка @mentions
|
||||||
|
- Парсинг `@user_id` из текста
|
||||||
|
- Создание уведомлений для упомянутых
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 6: Зависимости задач
|
||||||
|
|
||||||
|
### 6.1 Миграция
|
||||||
|
```php
|
||||||
|
task_dependencies (id, blocking_task_id, blocked_task_id, type)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Логика
|
||||||
|
- Проверка при completeTask(): если есть незавершённые blocking tasks — ошибка
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 7: Интеграция CRM → Tasks
|
||||||
|
|
||||||
|
### 7.1 Events
|
||||||
|
```php
|
||||||
|
Events::on('deal.created') → создать задачу "Первичный контакт"
|
||||||
|
Events::on('deal.stage_changed') → при этапе "КП" создать задачу
|
||||||
|
Events::on('deal.won') → создать задачу "Благодарность клиенту"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 8: Интеграция Booking → Tasks
|
||||||
|
|
||||||
|
```php
|
||||||
|
Events::on('booking.created') → создать задачу "Подготовка к встрече"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Приоритеты
|
||||||
|
|
||||||
|
| Этап | Задача | Оценка |
|
||||||
|
|------|--------|--------|
|
||||||
|
| 1 | RBAC + валидация | 4ч ✅ |
|
||||||
|
| 2 | Подзадачи | 8ч |
|
||||||
|
| 3 | Чек-листы | 6ч |
|
||||||
|
| 4 | Вложения | 8ч |
|
||||||
|
| 5 | Комментарии + @mentions | 12ч |
|
||||||
|
| 6 | Зависимости | 6ч |
|
||||||
|
| 7 | CRM → Tasks | 8ч |
|
||||||
|
| 8 | Booking → Tasks | 4ч |
|
||||||
|
|
||||||
|
**Всего:** ~56 часов
|
||||||
|
|
@ -216,6 +216,10 @@ class TasksController extends BaseController
|
||||||
*/
|
*/
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
|
if (!$this->access->canCreate('tasks')) {
|
||||||
|
return $this->forbiddenResponse('Нет прав для создания задач');
|
||||||
|
}
|
||||||
|
|
||||||
$organizationId = $this->requireActiveOrg();
|
$organizationId = $this->requireActiveOrg();
|
||||||
$boardId = (int) ($this->request->getGet('board') ?? 0);
|
$boardId = (int) ($this->request->getGet('board') ?? 0);
|
||||||
|
|
||||||
|
|
@ -263,15 +267,33 @@ class TasksController extends BaseController
|
||||||
*/
|
*/
|
||||||
public function store()
|
public function store()
|
||||||
{
|
{
|
||||||
|
if (!$this->access->canCreate('tasks')) {
|
||||||
|
return $this->forbiddenResponse('Нет прав для создания задач');
|
||||||
|
}
|
||||||
|
|
||||||
$organizationId = $this->requireActiveOrg();
|
$organizationId = $this->requireActiveOrg();
|
||||||
$userId = $this->getCurrentUserId();
|
$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 = [
|
$data = [
|
||||||
'organization_id' => $organizationId,
|
'organization_id' => $organizationId,
|
||||||
'board_id' => $this->request->getPost('board_id'),
|
'board_id' => (int) $this->request->getPost('board_id'),
|
||||||
'column_id' => $this->request->getPost('column_id'),
|
'column_id' => (int) $this->request->getPost('column_id'),
|
||||||
'title' => $this->request->getPost('title'),
|
'title' => trim($this->request->getPost('title')),
|
||||||
'description' => $this->request->getPost('description'),
|
'description' => trim($this->request->getPost('description')) ?: null,
|
||||||
'priority' => $this->request->getPost('priority') ?? 'medium',
|
'priority' => $this->request->getPost('priority') ?? 'medium',
|
||||||
'due_date' => $this->request->getPost('due_date') ?: null,
|
'due_date' => $this->request->getPost('due_date') ?: null,
|
||||||
];
|
];
|
||||||
|
|
@ -314,6 +336,10 @@ class TasksController extends BaseController
|
||||||
*/
|
*/
|
||||||
public function edit(int $id)
|
public function edit(int $id)
|
||||||
{
|
{
|
||||||
|
if (!$this->access->canEdit('tasks')) {
|
||||||
|
return $this->forbiddenResponse('Нет прав для редактирования задач');
|
||||||
|
}
|
||||||
|
|
||||||
$organizationId = $this->requireActiveOrg();
|
$organizationId = $this->requireActiveOrg();
|
||||||
$task = $this->taskService->getTask($id, $organizationId);
|
$task = $this->taskService->getTask($id, $organizationId);
|
||||||
|
|
||||||
|
|
@ -353,14 +379,32 @@ class TasksController extends BaseController
|
||||||
*/
|
*/
|
||||||
public function update(int $id)
|
public function update(int $id)
|
||||||
{
|
{
|
||||||
|
if (!$this->access->canEdit('tasks')) {
|
||||||
|
return $this->forbiddenResponse('Нет прав для редактирования задач');
|
||||||
|
}
|
||||||
|
|
||||||
$organizationId = $this->requireActiveOrg();
|
$organizationId = $this->requireActiveOrg();
|
||||||
$userId = $this->getCurrentUserId();
|
$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 = [
|
$data = [
|
||||||
'board_id' => $this->request->getPost('board_id'),
|
'board_id' => (int) $this->request->getPost('board_id'),
|
||||||
'column_id' => $this->request->getPost('column_id'),
|
'column_id' => (int) $this->request->getPost('column_id'),
|
||||||
'title' => $this->request->getPost('title'),
|
'title' => trim($this->request->getPost('title')),
|
||||||
'description' => $this->request->getPost('description'),
|
'description' => trim($this->request->getPost('description')) ?: null,
|
||||||
'priority' => $this->request->getPost('priority') ?? 'medium',
|
'priority' => $this->request->getPost('priority') ?? 'medium',
|
||||||
'due_date' => $this->request->getPost('due_date') ?: null,
|
'due_date' => $this->request->getPost('due_date') ?: null,
|
||||||
];
|
];
|
||||||
|
|
@ -383,6 +427,10 @@ class TasksController extends BaseController
|
||||||
*/
|
*/
|
||||||
public function destroy(int $id)
|
public function destroy(int $id)
|
||||||
{
|
{
|
||||||
|
if (!$this->access->canDelete('tasks')) {
|
||||||
|
return $this->forbiddenResponse('Нет прав для удаления задач');
|
||||||
|
}
|
||||||
|
|
||||||
$organizationId = $this->requireActiveOrg();
|
$organizationId = $this->requireActiveOrg();
|
||||||
$userId = $this->getCurrentUserId();
|
$userId = $this->getCurrentUserId();
|
||||||
|
|
||||||
|
|
@ -396,11 +444,25 @@ class TasksController extends BaseController
|
||||||
*/
|
*/
|
||||||
public function moveColumn()
|
public function moveColumn()
|
||||||
{
|
{
|
||||||
|
if (!$this->access->canEdit('tasks')) {
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Нет прав для изменения задач'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$organizationId = $this->requireActiveOrg();
|
$organizationId = $this->requireActiveOrg();
|
||||||
$userId = $this->getCurrentUserId();
|
$userId = $this->getCurrentUserId();
|
||||||
|
|
||||||
$taskId = $this->request->getPost('id');
|
$taskId = (int) $this->request->getPost('id');
|
||||||
$newColumnId = $this->request->getPost('column_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);
|
$result = $this->taskService->changeColumn($taskId, $newColumnId, $userId);
|
||||||
|
|
||||||
|
|
@ -418,6 +480,13 @@ class TasksController extends BaseController
|
||||||
*/
|
*/
|
||||||
public function complete(int $id)
|
public function complete(int $id)
|
||||||
{
|
{
|
||||||
|
if (!$this->access->canEdit('tasks')) {
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Нет прав для изменения задач'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$userId = $this->getCurrentUserId();
|
$userId = $this->getCurrentUserId();
|
||||||
$result = $this->taskService->completeTask($id, $userId);
|
$result = $this->taskService->completeTask($id, $userId);
|
||||||
|
|
||||||
|
|
@ -429,6 +498,13 @@ class TasksController extends BaseController
|
||||||
*/
|
*/
|
||||||
public function reopen(int $id)
|
public function reopen(int $id)
|
||||||
{
|
{
|
||||||
|
if (!$this->access->canEdit('tasks')) {
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Нет прав для изменения задач'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$userId = $this->getCurrentUserId();
|
$userId = $this->getCurrentUserId();
|
||||||
$result = $this->taskService->reopenTask($id, $userId);
|
$result = $this->taskService->reopenTask($id, $userId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ class TaskService
|
||||||
}
|
}
|
||||||
|
|
||||||
// Генерируем событие
|
// Генерируем событие
|
||||||
Events::trigger('tasks.created', $taskId, $data, $userId);
|
Events::trigger('task.created', $taskId, $data, $userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $taskId;
|
return $taskId;
|
||||||
|
|
@ -63,7 +63,7 @@ class TaskService
|
||||||
$result = $this->taskModel->update($taskId, $data);
|
$result = $this->taskModel->update($taskId, $data);
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
Events::trigger('tasks.updated', $taskId, $data, $userId);
|
Events::trigger('task.updated', $taskId, $data, $userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|
@ -96,7 +96,7 @@ class TaskService
|
||||||
$result = $this->taskModel->update($taskId, $data);
|
$result = $this->taskModel->update($taskId, $data);
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
Events::trigger('tasks.column_changed', $taskId, $oldColumnId, $newColumnId, $userId);
|
Events::trigger('task.column_changed', $taskId, $oldColumnId, $newColumnId, $userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|
@ -117,7 +117,7 @@ class TaskService
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
Events::trigger('tasks.completed', $taskId, $userId);
|
Events::trigger('task.completed', $taskId, $userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|
@ -138,7 +138,7 @@ class TaskService
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
Events::trigger('tasks.reopened', $taskId, $userId);
|
Events::trigger('task.reopened', $taskId, $userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|
@ -157,7 +157,7 @@ class TaskService
|
||||||
$result = $this->taskModel->delete($taskId);
|
$result = $this->taskModel->delete($taskId);
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
Events::trigger('tasks.deleted', $taskId, $userId);
|
Events::trigger('task.deleted', $taskId, $userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue