sync: latest changes before Gitea migration
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: "Leadera"
|
||||
weight: 1
|
||||
---
|
||||
|
||||
# Leadera
|
||||
|
||||
SaaS-система управления IT-ландшафтом с диаграммами Ганта.
|
||||
|
||||
- **Прод:** [leadera.pro](https://leadera.pro)
|
||||
- **Дев:** [sand.a2v.space](https://sand.a2v.space)
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: "Leadera — Экспорт диаграммы Ганта в Excel"
|
||||
date: 2026-04-09
|
||||
lastmod: 2026-04-09
|
||||
tags: ["leadera", "gantt", "excel", "export"]
|
||||
weight: 120
|
||||
---
|
||||
|
||||
# Экспорт диаграммы Ганта в Excel
|
||||
|
||||
**Дата:** 09.04.2026
|
||||
**Статус:** Требования
|
||||
**Проект:** Leadera
|
||||
|
||||
---
|
||||
|
||||
## Общее описание
|
||||
|
||||
Пользователь нажимает кнопку экспорта в интерфейсе диаграммы Ганта. Система запрашивает диапазон дат выгрузки (по умолчанию — текущий видимый диапазон из интерфейса). После указания диапазона скачивается Excel-файл с визуальным представлением диаграммы Ганта.
|
||||
|
||||
---
|
||||
|
||||
## Вводные данные
|
||||
|
||||
- **Источник:** текущая диаграмма Ганта проекта (один проект = одна диаграмма)
|
||||
- **Диапазон дат:** пользователь указывает начальную и конечную дату. По умолчанию подставляется видимый в момент запроса диапазон из интерфейса
|
||||
- **Масштаб таймлайна:** берётся тот, который выбран пользователем в интерфейсе в момент запроса (не сохранённый в системе, а текущий). Варианты: по ячейкам или по дням
|
||||
|
||||
---
|
||||
|
||||
## Структура Excel-файла
|
||||
|
||||
### Шапка (первые строки)
|
||||
- Название диаграммы (название проекта)
|
||||
- Диапазон дат, в рамках которого сделана выгрузка
|
||||
|
||||
### Лист — один
|
||||
|
||||
Вся диаграмма размещается на одном листе.
|
||||
|
||||
### Секции
|
||||
- Секции отображаются как **визуальная группировка строк** (не отдельные листы)
|
||||
- Название секции — строка-заголовок над группой задач
|
||||
|
||||
### Задачи
|
||||
Каждая задача — отдельная строка. Данные задачи:
|
||||
- **Название** (текстовая колонка слева)
|
||||
- **Дата начала** (текстовая колонка слева)
|
||||
- **Дата конца** (текстовая колонка слева)
|
||||
- **Цвет** — ячейки таймлайна, соответствующие задаче, закрашены в цвет задачи
|
||||
|
||||
### Таймлайн
|
||||
- Таймлайн представлен колонками дат в верхней части (горизонтальная ось времени)
|
||||
- Масштаб колонок соответствует выбранному пользователем в момент экспорта (по ячейкам / по дням)
|
||||
- Задачи отображаются как **merged cells** по соответствующим колонкам дат — визуальная полоса диаграммы Ганта
|
||||
|
||||
### Вехи
|
||||
- Отображаются **отдельной строкой** (как задача, но с маркировкой вехи)
|
||||
- Позиционируются на соответствующей дате в таймлайне
|
||||
|
||||
### Зависимости
|
||||
- **Не отображаются** (стрелочки/связи между задачами не нужны)
|
||||
|
||||
---
|
||||
|
||||
## Пользовательский сценарий
|
||||
|
||||
1. Пользователь открывает диаграмму Ганта проекта
|
||||
2. Настраивает масштаб таймлайна (по ячейкам / по дням)
|
||||
3. Нажимает кнопку «Экспорт в Excel»
|
||||
4. Система показывает диалог с выбором диапазона дат (предзаполнен текущим видимым диапазоном)
|
||||
5. Пользователь подтверждает / корректирует диапазон
|
||||
6. Система генерирует Excel-файл и инициирует скачивание
|
||||
|
||||
---
|
||||
|
||||
## Технические требования
|
||||
|
||||
### Backend (Go API)
|
||||
- Новый endpoint для генерации Excel (например, `GET /api/v1/gantt/:projectId/export`)
|
||||
- Query-параметры: `dateFrom`, `dateTo`, `scale` (cell|day)
|
||||
- Ответ: binary Excel-файл (`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`)
|
||||
- Авторизация: обязательна (только участники проекта)
|
||||
- Библиотека для генерации Excel: `excelize` (Go)
|
||||
|
||||
### Frontend (Angular)
|
||||
- Кнопка экспорта в тулбаре диаграммы Ганта
|
||||
- Модальное окно выбора диапазона дат с datepicker'ами
|
||||
- Предзаполнение диапазона из текущего видимого viewport'а
|
||||
- Скачивание файла через `Blob` / `saveAs`
|
||||
|
||||
### Фильтрация данных
|
||||
- Выгружаются все задачи и вехи, которые:
|
||||
- Начинаются в диапазоне, ИЛИ
|
||||
- Заканчиваются в диапазоне, ИЛИ
|
||||
- Начинаются до диапазона и заканчиваются после (перекрывают целиком)
|
||||
- Секции включаются, если в них есть хотя бы одна попавшая задача
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
_(Нет)_
|
||||
@@ -0,0 +1,278 @@
|
||||
---
|
||||
title: "Leadera — Фильтры и реорганизация списка диаграмм Ганта"
|
||||
date: 2026-04-07
|
||||
lastmod: 2026-04-07
|
||||
tags: ["leadera", "gantt", "api", "frontend", "requirements"]
|
||||
weight: 120
|
||||
---
|
||||
|
||||
# Фильтры и реорганизация списка диаграмм Ганта
|
||||
|
||||
**Дата:** 07.04.2026
|
||||
**Статус:** Требования
|
||||
**Проект:** Leadera (dev — sand.a2v.space)
|
||||
|
||||
---
|
||||
|
||||
## 1. Постановка задачи
|
||||
|
||||
Список диаграмм Ганта должен показывать **все доступные пользователю диаграммы** из всех пространств (spaces), с возможностью фильтрации и поиска. Сейчас список привязан к одному пространству — нужно убрать эту зависимость и добавить удобный toolbar.
|
||||
|
||||
---
|
||||
|
||||
## 2. Изменения API
|
||||
|
||||
### 2.1. Новый endpoint списка диаграмм
|
||||
|
||||
**Было:**
|
||||
```
|
||||
GET /api/v1/spaces/{spaceId}/gantt-charts?page=1&per_page=10
|
||||
```
|
||||
|
||||
**Стало:**
|
||||
```
|
||||
GET /api/v1/gantt-charts?page=1&per_page=10
|
||||
```
|
||||
|
||||
Пользователь видит все диаграммы из **всех пространств**, где он является подтверждённым участником (статус `accepted`).
|
||||
|
||||
### 2.2. Параметры фильтрации и поиска
|
||||
|
||||
| Параметр | Тип | Описание |
|
||||
|----------|-----|----------|
|
||||
| `page` | int | Номер страницы (default: 1) |
|
||||
| `per_page` | int | Записей на странице (default: 10) |
|
||||
| `space_id` | UUID (optional) | Фильтр по конкретному пространству |
|
||||
| `created_by` | UUID (optional) | Фильтр по создателю диаграммы |
|
||||
| `search` | string (optional) | Поиск по назвению и описанию (ILIKE, case-insensitive) |
|
||||
| `sort` | string (optional) | Сортировка: `created_at`, `updated_at`, `title` (default: `updated_at`) |
|
||||
| `order` | string (optional) | `asc` / `desc` (default: `desc`) |
|
||||
|
||||
**Примеры:**
|
||||
```
|
||||
GET /api/v1/gantt-charts?search=миграция
|
||||
GET /api/v1/gantt-charts?space_id=0b334ded-...&created_by=a1b2c3d4-...
|
||||
GET /api/v1/gantt-charts?page=2&per_page=20&sort=title&order=asc
|
||||
```
|
||||
|
||||
### 2.3. Изменение структуры ответа
|
||||
|
||||
Добавить поле `space` в каждую диаграмму:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "Миграция backend",
|
||||
"description": "...",
|
||||
"created_by": "uuid",
|
||||
"created_by_name": "Антон Агальцов",
|
||||
"created_at": "2026-04-01T10:00:00Z",
|
||||
"updated_at": "2026-04-07T09:00:00Z",
|
||||
"space": {
|
||||
"id": "uuid",
|
||||
"name": "IT Ландшафт",
|
||||
"color": "#c4623a"
|
||||
},
|
||||
"is_archived": false
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"per_page": 10,
|
||||
"total": 42,
|
||||
"total_pages": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4. Endpoint для списка фильтров
|
||||
|
||||
Вспомогательный endpoint для заполнения dropdown-фильтров:
|
||||
|
||||
```
|
||||
GET /api/v1/gantt-charts/filters
|
||||
```
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"spaces": [
|
||||
{ "id": "uuid", "name": "IT Ландшафт", "color": "#c4623a" },
|
||||
{ "id": "uuid", "name": "DevOps", "color": "#27ae60" }
|
||||
],
|
||||
"creators": [
|
||||
{ "id": "uuid", "display_name": "Антон Агальцов" },
|
||||
{ "id": "uuid", "display_name": "Иван Петров" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Логика `creators`:** уникальные создатели всех диаграмм, доступных текущему пользователю. Получается через DISTINCT-запрос по `created_by` с JOIN на users.
|
||||
|
||||
### 2.5. Backend — изменения в Go
|
||||
|
||||
**Файлы для изменения:**
|
||||
|
||||
#### `internal/handlers/gantt_handler.go`
|
||||
|
||||
- Добавить новый handler `ListAllGanttCharts` — без привязки к space
|
||||
- Зарегистрировать маршрут `GET /gantt-charts` (без `/spaces/:id/`)
|
||||
- Парсить query-параметры: `space_id`, `created_by`, `search`, `sort`, `order`
|
||||
- Старый маршрут `GET /spaces/:id/gantt-charts` — оставить для обратной совместимости (или удалить, если не используется)
|
||||
|
||||
#### `internal/service/gantt_service.go`
|
||||
|
||||
- Новый метод `ListAll(ctx, userId, filters)` — запрашивает все spaces пользователя (через `space_member` с `status=accepted`), затем выбирает все gantt_charts из этих spaces с фильтрами
|
||||
|
||||
#### `internal/repository/gantt_repository.go`
|
||||
|
||||
- Новый метод с динамическим SQL:
|
||||
- Базовый JOIN: `gantt_charts` → `spaces` → `space_members`
|
||||
- WHERE: `space_members.user_id = $1 AND space_members.status = 'accepted'`
|
||||
- Опциональные фильтры: `AND space_id = $N`, `AND created_by = $N`, `AND (title ILIKE '%' || $N || '%' OR description ILIKE '%' || $N || '%')`
|
||||
- Возвращать поле `space` (id, name, color) через JOIN
|
||||
|
||||
#### `internal/dto/gantt/`
|
||||
|
||||
- Новые DTO: `GanttChartListFilters`, `GanttChartWithSpaceResponse`, `GanttFiltersResponse`
|
||||
|
||||
**SQL (пример):**
|
||||
```sql
|
||||
SELECT gc.*, s.id as space_id, s.name as space_name, s.color as space_color,
|
||||
u.display_name as created_by_name
|
||||
FROM gantt_charts gc
|
||||
JOIN spaces s ON s.id = gc.space_id
|
||||
JOIN space_members sm ON sm.space_id = s.id
|
||||
LEFT JOIN users u ON u.id = gc.created_by
|
||||
WHERE sm.user_id = $1
|
||||
AND sm.status = 'accepted'
|
||||
AND ($2::uuid IS NULL OR gc.space_id = $2)
|
||||
AND ($3::uuid IS NULL OR gc.created_by = $3)
|
||||
AND ($4::text IS NULL OR gc.title ILIKE '%' || $4 || '%'
|
||||
OR gc.description ILIKE '%' || $4 || '%')
|
||||
ORDER BY
|
||||
CASE WHEN $5 = 'title' THEN gc.title END,
|
||||
CASE WHEN $5 = 'created_at' THEN gc.created_at::text END,
|
||||
gc.updated_at DESC
|
||||
LIMIT $6 OFFSET $7;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Изменения Frontend (Angular)
|
||||
|
||||
### 3.1. Toolbar фильтров
|
||||
|
||||
Добавить toolbar над списком диаграмм:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ [🔍 Поиск... ] [▼ Пространство] │
|
||||
│ [▼ Создатель] [▼ Сортировка] [Сбросить фильтры] │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Компоненты:**
|
||||
- **Строка поиска** — input с debounce (300ms), поиск по назвению и описанию
|
||||
- **Dropdown «Пространство»** — список из `/gantt-charts/filters`, с цветовой меткой
|
||||
- **Dropdown «Создатель»** — список уникальных создателей
|
||||
- **Dropdown «Сортировка»** — по дате обновления, дате создания, названию
|
||||
- **Кнопка «Сбросить»** — очищает все фильтры
|
||||
|
||||
**Состояние фильтров в URL** (query params):
|
||||
```
|
||||
/gantt?search=миграция&space_id=xxx&sort=updated_at
|
||||
```
|
||||
Это позволяет делиться ссылкой с уже настроенными фильтрами.
|
||||
|
||||
### 3.2. Карточка диаграммы — бейдж пространства
|
||||
|
||||
На каждой карточке добавить бейдж с названием пространства:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🟠 IT Ландшафт │ ← бейдж с цветом пространства
|
||||
│ Миграция backend │
|
||||
│ Перевод PHP→Go, этап 2 │
|
||||
│ Создал: Антон А. · 07.04.2026 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.3. Форма создания диаграммы — выбор пространства
|
||||
|
||||
В модальном окне создания:
|
||||
- Dropdown со списком пространств, где пользователь имеет роль **editor** или выше
|
||||
- Валидация: если пользователь не editor ни в одном пространстве — показать сообщение «Нет доступных пространств для создания»
|
||||
|
||||
**Запрос доступных пространств:** переиспользовать существующий `GET /api/v1/spaces` с фильтрацией на клиенте по `role ∈ [owner, admin, editor]`, либо добавить параметр `?min_role=editor` на backend.
|
||||
|
||||
### 3.4. Изменение routing
|
||||
|
||||
**Было:**
|
||||
```
|
||||
/gantt → список гантов текущего пространства
|
||||
```
|
||||
|
||||
**Стало:**
|
||||
```
|
||||
/gantt → список всех доступных гантов (из всех пространств)
|
||||
```
|
||||
|
||||
Убрать зависимость от `SpaceStorageService.getSelectedSpaceId()` в списке гантов.
|
||||
|
||||
### 3.5. Frontend — файлы для изменения
|
||||
|
||||
| Файл | Изменение |
|
||||
|------|-----------|
|
||||
| `pages/gantt/services/gantt.service.ts` | Новый метод `listAll(filters)`, `getFilters()` |
|
||||
| `pages/gantt/gantt.component.ts` | Подключить toolbar, парсинг URL-параметров |
|
||||
| `pages/gantt/gantt.component.html` | Добавить toolbar, бейджи на карточках |
|
||||
| `pages/gantt/components/gantt-card/` | Добавить бейдж пространства |
|
||||
| `pages/gantt/components/create-chart-modal/` | Dropdown выбора пространства |
|
||||
| `core/models/` | Обновить модели: добавить `space` в GanttChart |
|
||||
|
||||
---
|
||||
|
||||
## 4. Обратная совместимость
|
||||
|
||||
| Endpoint | Статус |
|
||||
|----------|--------|
|
||||
| `GET /api/v1/spaces/{id}/gantt-charts` | Сохранить (для детального view внутри пространства) |
|
||||
| `GET /api/v1/gantt-charts` | **Новый** — глобальный список |
|
||||
| `GET /api/v1/gantt-charts/filters` | **Новый** — данные для dropdown'ов |
|
||||
| `POST /api/v1/spaces/{id}/gantt-charts` | Сохранить (создание в контексте пространства) |
|
||||
| Все CRUD для sections/tasks | Без изменений |
|
||||
|
||||
---
|
||||
|
||||
## 5. Критерии приёмки
|
||||
|
||||
- [ ] Endpoint `GET /api/v1/gantt-charts` возвращает все доступные диаграммы
|
||||
- [ ] Фильтр по `space_id` — показывает только диаграммы конкретного пространства
|
||||
- [ ] Фильтр по `created_by` — показывает только диаграммы выбранного создателя
|
||||
- [ ] Поиск `search` — ищет по title и description (case-insensitive)
|
||||
- [ ] Dropdown фильтров заполняется реальными данными из `/filters`
|
||||
- [ ] Карточка диаграммы показывает бейдж пространства с цветом
|
||||
- [ ] Форма создания предлагает только пространства с ролью ≥ editor
|
||||
- [ ] Состояние фильтров сохраняется в URL (можно поделиться ссылкой)
|
||||
- [ ] Старые endpoints (`/spaces/{id}/gantt-charts`) продолжают работать
|
||||
- [ ] Swagger документация обновлена
|
||||
|
||||
---
|
||||
|
||||
## 6. Порядок реализации
|
||||
|
||||
1. Backend: DTO для новых запросов/ответов
|
||||
2. Backend: Repository — SQL-запрос с фильтрами + JOIN на spaces
|
||||
3. Backend: Service — `ListAll`, `GetFilters`
|
||||
4. Backend: Handler — `ListAllGanttCharts`, `GetGanttFilters`, регистрация маршрутов
|
||||
5. Backend: Swagger — обновить документацию
|
||||
6. Frontend: Модели — обновить GanttChart, добавить Space в ответ
|
||||
7. Frontend: Service — `listAll()`, `getFilters()`
|
||||
8. Frontend: Toolbar компонент — поиск + dropdown'ы
|
||||
9. Frontend: Карточка — бейдж пространства
|
||||
10. Frontend: Модальное окно создания — dropdown пространства
|
||||
11. Frontend: URL-параметры — синхронизация фильтров с router
|
||||
12. Тестирование
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: "Leadera — Публичный доступ к диаграмме Ганта"
|
||||
date: 2026-04-09
|
||||
lastmod: 2026-04-09
|
||||
tags: ["leadera", "gantt", "public-access", "sharing"]
|
||||
weight: 130
|
||||
---
|
||||
|
||||
# Публичный доступ к диаграмме Ганта (Share)
|
||||
|
||||
**Дата:** 09.04.2026
|
||||
**Статус:** Требования
|
||||
**Проект:** Leadera
|
||||
|
||||
---
|
||||
|
||||
## Общее описание
|
||||
|
||||
Пользователь может создать публичную ссылку на фрагмент диаграммы Ганта, доступную для просмотра без авторизации. Открывший ссылку видит диаграмму в read-only режиме с возможностью масштабирования таймлайна, но без каких-либо элементов управления (создание, редактирование, удаление задач/секций/вех).
|
||||
|
||||
---
|
||||
|
||||
## Пользовательский сценарий (создатель ссылки)
|
||||
|
||||
1. Пользователь открывает диаграмму Ганта проекта
|
||||
2. Нажимает кнопку «Экспорт» / «Общий доступ»
|
||||
3. Указывает диапазон дат, который должен отображаться на диаграмме
|
||||
4. Система генерирует уникальную ссылку (UUID) и показывает её пользователю
|
||||
5. Пользователь копирует ссылку и передаёт заинтересованным лицам
|
||||
|
||||
### Управление ссылками
|
||||
- Вызывается через меню экспорта → модальное окно «Общий доступ»
|
||||
- В модальном окне отображается список созданных ссылок с указанием диапазона дат
|
||||
- Каждую ссылку можно **удалить** (полное удаление из БД, не soft delete)
|
||||
- Ссылки **нельзя редактировать** — только создавать и удалять
|
||||
- Ограничений на количество активных ссылок нет
|
||||
- Статистика просмотров (сколько раз открывали) — **не нужна**
|
||||
|
||||
---
|
||||
|
||||
## Пользовательский сценарий (зритель по ссылке)
|
||||
|
||||
1. Зритель переходит по ссылке (без авторизации)
|
||||
2. Открывается отдельная страница с диаграммой Ганта
|
||||
3. Зритель видит:
|
||||
- Задачи и вехи, попадающие в заданный диапазон
|
||||
- Секции (заголовки группировок)
|
||||
- Таймлайн с цветовой раскраской задач
|
||||
4. Зритель может:
|
||||
- Масштабировать таймлайн
|
||||
- Переключать отображение «по ячейкам» / «по дням»
|
||||
5. Зритель **не может**:
|
||||
- Создавать, редактировать, удалять задачи / секции / вехи
|
||||
- Видеть любые контролы управления (кнопки создания, контекстные меню, drag-and-drop и т.д.)
|
||||
- Авторизоваться через эту страницу
|
||||
|
||||
---
|
||||
|
||||
## Данные и фильтрация
|
||||
|
||||
### Что попадает в представление
|
||||
Задачи и вехи, у которых хотя бы одно из условий верно:
|
||||
- Дата начала попадает в диапазон ссылки
|
||||
- Дата конца попадает в диапазон ссылки
|
||||
- Задача начинается до диапазона и заканчивается после (полностью перекрывает)
|
||||
|
||||
Секции включаются, если в них есть хотя бы одна попавшая задача.
|
||||
|
||||
### API возвращает
|
||||
- Только задачи и вехи, попадающие в диапазон (не всю диаграмму)
|
||||
- Информацию о секциях, содержащих попавшие задачи
|
||||
- Минимальный набор полей, необходимый для отрисовки (без внутренних ID пользователей и т.п.)
|
||||
|
||||
---
|
||||
|
||||
## Технические требования
|
||||
|
||||
### Ссылки
|
||||
- Формат: UUID v4 (каждый раз новый)
|
||||
- Срок действия: бессрочно (пока не удалена)
|
||||
- Удаление: **жёсткое** (hard delete из БД)
|
||||
|
||||
### Backend (Go API)
|
||||
|
||||
#### Таблица БД: `gantt_public_links`
|
||||
- `id` (UUID, PK)
|
||||
- `project_id` (FK → projects)
|
||||
- `date_from` (date)
|
||||
- `date_to` (date)
|
||||
- `created_by` (FK → users)
|
||||
- `created_at` (timestamp)
|
||||
|
||||
#### Endpoints
|
||||
- `POST /api/v1/gantt/:projectId/public-links` — создать ссылку (авторизован, участник проекта)
|
||||
- Body: `{ dateFrom, dateTo }`
|
||||
- Response: `{ url: "https://leadera.pro/share/<uuid>" }`
|
||||
- `GET /api/v1/gantt/:projectId/public-links` — список ссылок проекта (авторизован, участник)
|
||||
- `DELETE /api/v1/gantt/public-links/:uuid` — удалить ссылку (авторизован, создатель)
|
||||
- `GET /api/v1/public/gantt/:uuid` — получить данные диаграммы по ссылке (без авторизации)
|
||||
- Response: задачи, вехи, секции в рамках диапазона
|
||||
|
||||
#### Rate limiting
|
||||
- На публичный endpoint `GET /api/v1/public/gantt/:uuid` — rate limiting для защиты от перебора
|
||||
- Рекомендация: 60 запросов в минуту на IP
|
||||
|
||||
### Frontend (Angular)
|
||||
|
||||
#### Модальное окно «Общий доступ»
|
||||
- Вызывается из меню экспорта
|
||||
- Содержит:
|
||||
- Форму создания ссылки (диапазон дат + кнопка «Создать»)
|
||||
- Список существующих ссылок (диапазон + URL + кнопка «Копировать» + кнопка «Удалить»)
|
||||
|
||||
#### Публичная страница `/share/:uuid`
|
||||
- Отдельный route, **не требует авторизации**
|
||||
- Отображает диаграмму Ганта в read-only режиме
|
||||
- Доступны только: масштабирование таймлайна, переключение ячейки/дни
|
||||
- Все контролы управления скрыты (удалены из шаблона, не просто disabled)
|
||||
- Минимальный layout — без навигации проекта, без сайдбара
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
_(Нет)_
|
||||
@@ -0,0 +1,329 @@
|
||||
---
|
||||
title: "Leadera: Вехи (Milestones) на диаграмме Ганта"
|
||||
date: 2026-04-08
|
||||
tags: ["leadera", "gantt", "angular", "go", "api", "ux"]
|
||||
---
|
||||
|
||||
## Бизнес-требования
|
||||
|
||||
### BR-1: Сущность «Веха» (Milestone)
|
||||
|
||||
Веха — это маркер на таймлайне диаграммы Ганта, обозначающий важную дату в проекте. В отличие от задач, веха не имеет длительности и привязана к конкретной дате.
|
||||
|
||||
**Поля вехи:**
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | UUID | Идентификатор |
|
||||
| gantt_chart_id | UUID | Принадлежность к диаграмме (не к секции!) |
|
||||
| title | string | Название вехи (обязательное, 1–255 символов) |
|
||||
| date | date | Дата вехи (без времени, обязательная) |
|
||||
| color | string | Цвет маркера (HEX, по умолчанию `#dc3545` — красный) |
|
||||
| created_by | UUID | Кто создал (не отображается в UI, хранится для аудита) |
|
||||
| updated_by | UUID | Кто обновил (не отображается в UI) |
|
||||
| created_at | timestamp | Дата создания |
|
||||
| updated_at | timestamp | Дата обновления |
|
||||
| deleted_at | timestamp | Soft delete |
|
||||
|
||||
**Права доступа:** Создание, редактирование и удаление вех доступно участникам с правами выше «только чтение» (аналогично задачам).
|
||||
|
||||
**Особенности:**
|
||||
- Вех может быть несколько в проекте
|
||||
- Несколько вех могут иметь одну и ту же дату
|
||||
- Вехи сортируются строго по дате (по возрастанию)
|
||||
- Вехи не привязаны к секциям — они отображаются **сквозь все секции**
|
||||
|
||||
---
|
||||
|
||||
### BR-2: Управление вехами (модальное окно)
|
||||
|
||||
**Вызов:** Кнопка «Вехи» рядом с кнопкой «Создать секцию» на странице диаграммы.
|
||||
|
||||
**Модальное окно содержит:**
|
||||
- Заголовок: «Вехи проекта»
|
||||
- Список всех вех текущей диаграммы (сортировка по дате по возрастанию)
|
||||
- Каждая веха в списке показывает: цвет (индикатор), название, дату, кнопки «Редактировать» и «Удалить»
|
||||
- Кнопка «Добавить веху»
|
||||
|
||||
**Форма добавления/редактирования вехи:**
|
||||
- Название — текстовый ввод (обязательное, 1–255 символов)
|
||||
- Дата — datepicker (обязательное)
|
||||
- Цвет — выбор цвета (по аналогии с палитрой цветов в задачах)
|
||||
- По умолчанию цвет = красный (`#dc3545`)
|
||||
|
||||
**Удаление:** с подтверждением (confirm dialog).
|
||||
|
||||
---
|
||||
|
||||
### BR-3: Отображение вех на таймлайне
|
||||
|
||||
**Визуальное представление:**
|
||||
- **Маркер-флажок** (flag marker) на шкале таймлайна
|
||||
- От маркера вниз идёт **пунктирная вертикальная линия** через все секции (на всю высоту диаграммы)
|
||||
- Цвет линии и маркера = цвет вехи
|
||||
|
||||
**Позиционирование:**
|
||||
- Веха ставится ровно на свою дату на шкале времени
|
||||
- Если дата вехи выходит за рамки текущего диапазона задач — таймлайн **расширяется** (аналогично задачам)
|
||||
- При изменении масштаба (days/weeks/months/quarters) позиция вехи пересчитывается
|
||||
|
||||
**Tooltip при наведении:**
|
||||
- При наведении на маркер/линию — показывается tooltip с названием вехи
|
||||
- Если на одну дату приходится несколько вех — в tooltip показываются **все** вехи этой даты с их названиями (каждая с индикацией своего цвета)
|
||||
|
||||
---
|
||||
|
||||
## Технические требования
|
||||
|
||||
### База данных
|
||||
|
||||
#### Новая таблица: gantt_milestones
|
||||
|
||||
```sql
|
||||
CREATE TABLE gantt_milestones (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
gantt_chart_id UUID NOT NULL REFERENCES gantt_charts(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#dc3545',
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
updated_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_gantt_milestones_chart ON gantt_milestones(gantt_chart_id);
|
||||
CREATE INDEX idx_gantt_milestones_date ON gantt_milestones(date);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Backend (Go API)
|
||||
|
||||
#### Этап 1: Миграция + Домен
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Добавь в проект новую таблицу `gantt_milestones`.
|
||||
>
|
||||
> 1. Создай миграцию в `cmd/migrate/`:
|
||||
> - Новая таблица `gantt_milestones` с полями: id (UUID PK), gantt_chart_id (UUID FK → gantt_charts, ON DELETE CASCADE), title (VARCHAR 255 NOT NULL), date (DATE NOT NULL), color (VARCHAR 20 NOT NULL DEFAULT '#dc3545'), created_by (UUID FK → users), updated_by (UUID FK → users), created_at, updated_at, deleted_at. Индексы по gantt_chart_id и date.
|
||||
>
|
||||
> 2. Создай доменную модель `GanttMilestone` в `internal/domain/gantt.go`:
|
||||
> ```go
|
||||
> type GanttMilestone struct {
|
||||
> ID UUID `json:"id"`
|
||||
> GanttChartID UUID `json:"gantt_chart_id"`
|
||||
> Title string `json:"title"`
|
||||
> Date time.Time `json:"date"`
|
||||
> Color string `json:"color"`
|
||||
> CreatedBy UUID `json:"created_by"`
|
||||
> UpdatedBy UUID `json:"updated_by"`
|
||||
> CreatedAt time.Time `json:"created_at"`
|
||||
> UpdatedAt time.Time `json:"updated_at"`
|
||||
> DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
> }
|
||||
> ```
|
||||
|
||||
#### Этап 2: CRUD для GanttMilestones
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Реализуй полный CRUD для сущности `GanttMilestone` по аналогии с существующими handlers/services/repositories в проекте.
|
||||
>
|
||||
> **Роуты:**
|
||||
> - `GET /api/gantt-charts/:chartId/milestones` — список вех диаграммы
|
||||
> - `POST /api/gantt-charts/:chartId/milestones` — создать веху
|
||||
> - `PUT /api/gantt-charts/:chartId/milestones/:milestoneId` — обновить веху
|
||||
> - `DELETE /api/gantt-charts/:chartId/milestones/:milestoneId` — удалить веху (soft delete)
|
||||
>
|
||||
> **Repository:** `internal/repository/gantt_milestone_repository.go`
|
||||
> - `GetByChartID(ctx, chartID) ([]domain.GanttMilestone, error)` — все неудалённые вехи диаграммы, отсортированные по date ASC
|
||||
> - `GetByID(ctx, id) (*domain.GanttMilestone, error)`
|
||||
> - `Create(ctx, milestone) error`
|
||||
> - `Update(ctx, milestone) error`
|
||||
> - `Delete(ctx, id) error` — soft delete (set deleted_at)
|
||||
> - Все запросы — raw SQL через pgx, как в остальных репозиториях проекта
|
||||
>
|
||||
> **Service:** `internal/service/gantt_milestone_service.go`
|
||||
> - Валидация: title (1–255 символов), date (обязательное), color (валидный HEX, default '#dc3545')
|
||||
> - Проверка прав доступа: пользователь должен иметь права выше «только чтение» в пространстве (space) диаграммы
|
||||
> - Проверка что chartId существует и не архивирован
|
||||
>
|
||||
> **Handler:** `internal/handler/gantt_milestone_handler.go`
|
||||
> - Swagger-аннотации для всех эндпоинтов
|
||||
> - Валидация входных данных
|
||||
> - Ошибки в формате проекта
|
||||
>
|
||||
> **DTO:** `internal/dto/gantt/milestones_dto.go`
|
||||
> ```go
|
||||
> type CreateGanttMilestoneRequest struct {
|
||||
> Title string `json:"title" binding:"required,min=1,max=255"`
|
||||
> Date string `json:"date" binding:"required"`
|
||||
> Color *string `json:"color" binding:"omitempty,max=20"`
|
||||
> }
|
||||
>
|
||||
> type UpdateGanttMilestoneRequest struct {
|
||||
> Title *string `json:"title" binding:"omitempty,min=1,max=255"`
|
||||
> Date *string `json:"date" binding:"omitempty"`
|
||||
> Color *string `json:"color" binding:"omitempty,max=20"`
|
||||
> }
|
||||
>
|
||||
> type GanttMilestoneResponse struct {
|
||||
> ID domain.UUID `json:"id"`
|
||||
> GanttChartID domain.UUID `json:"gantt_chart_id"`
|
||||
> Title string `json:"title"`
|
||||
> Date string `json:"date"`
|
||||
> Color string `json:"color"`
|
||||
> CreatedByUser *CreatedByUpdatedUser `json:"created_by_user"`
|
||||
> UpdatedByUser *CreatedByUpdatedUser `json:"updated_by_user"`
|
||||
> CreatedAt time.Time `json:"created_at"`
|
||||
> UpdatedAt time.Time `json:"updated_at"`
|
||||
> }
|
||||
>
|
||||
> type GanttMilestonesListResponse struct {
|
||||
> Milestones []GanttMilestoneResponse `json:"milestones"`
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **Регистрация роутов:** Добавь группу milestone-роутов в роутер рядом с существующими роутами секций/задач. Проверка прав через существующий middleware.
|
||||
|
||||
#### Этап 3: Включение вех в ответ диаграммы
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> При запросе данных диаграммы Ганта (полная загрузка) включай связанные вехи в ответ.
|
||||
>
|
||||
> 1. Добавь эндпоинт или расширь существующий `GET /api/gantt-charts/:chartId` — включить поле `milestones` с массивом `GanttMilestoneResponse`
|
||||
> 2. Вехи с `deleted_at IS NOT NULL` не возвращаются
|
||||
> 3. Вехи сортируются по `date` ASC
|
||||
> 4. Вехи загружаются вместе с данными диаграммы (tasks, sections) — один запрос при открытии Ганта
|
||||
|
||||
---
|
||||
|
||||
### Frontend (Angular)
|
||||
|
||||
#### Этап FE-1: Модели + Сервис
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Добавь в проект поддержку вех (milestones) на диаграмме Ганта.
|
||||
>
|
||||
> 1. В `pages/gantt/models/` (или соответствующем файле моделей) добавь:
|
||||
>
|
||||
> ```typescript
|
||||
> export interface GanttMilestone {
|
||||
> id: string;
|
||||
> gantt_chart_id: string;
|
||||
> title: string;
|
||||
> date: string;
|
||||
> color: string;
|
||||
> created_by_user?: { id: string; full_name: string };
|
||||
> updated_by_user?: { id: string; full_name: string };
|
||||
> created_at: string;
|
||||
> updated_at: string;
|
||||
> }
|
||||
>
|
||||
> export interface GanttMilestoneCreateRequest {
|
||||
> title: string;
|
||||
> date: string;
|
||||
> color?: string;
|
||||
> }
|
||||
>
|
||||
> export interface GanttMilestoneUpdateRequest {
|
||||
> title?: string;
|
||||
> date?: string;
|
||||
> color?: string;
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 2. Добавь `milestones: GanttMilestone[]` в интерфейс/модель полной загрузки данных диаграммы
|
||||
>
|
||||
> 3. Создай сервис `pages/gantt/services/gantt-milestone.service.ts`:
|
||||
> - Inject HttpClient
|
||||
> - Методы:
|
||||
> - `getMilestones(chartId: string): Observable<GanttMilestone[]>`
|
||||
> - `createMilestone(chartId: string, data: GanttMilestoneCreateRequest): Observable<GanttMilestone>`
|
||||
> - `updateMilestone(chartId: string, milestoneId: string, data: GanttMilestoneUpdateRequest): Observable<GanttMilestone>`
|
||||
> - `deleteMilestone(chartId: string, milestoneId: string): Observable<void>`
|
||||
> - Все методы возвращают Observable с типизированными ответами (по аналогии с GanttChartService)
|
||||
|
||||
#### Этап FE-2: Модальное окно управления вехами
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Создай компонент модального окна для управления вехами диаграммы Ганта.
|
||||
>
|
||||
> 1. Создай компонент `pages/gantt/components/milestones-modal/`
|
||||
>
|
||||
> 2. **Кнопка вызова:** Добавь кнопку «Вехи» рядом с кнопкой «Создать секцию» в toolbar диаграммы. По клику — открытие модального окна через ng-bootstrap NgbModal.
|
||||
>
|
||||
> 3. **Модальное окно** (ng-bootstrap modal):
|
||||
> - Заголовок: «Вехи проекта»
|
||||
> - Список вех (сортировка по date ASC):
|
||||
> - Каждый элемент: цветной кружок (color), название, дата (форматированная), кнопки ✏️ (редактировать) и 🗑️ (удалить с confirm)
|
||||
> - Кнопка «Добавить веху» внизу списка
|
||||
> - Если вех нет — сообщение «Вех пока нет. Добавьте первую!»
|
||||
>
|
||||
> 4. **Форма добавления/редактирования** (inline в модальном окне):
|
||||
> - Название — текстовый ввод (обязательное)
|
||||
> - Дата — ng-bootstrap datepicker (обязательное)
|
||||
> - Цвет — палитра цветов (использовать тот же компонент/подход, что и для задач)
|
||||
> - По умолчанию цвет = `#dc3545` (красный)
|
||||
> - Кнопки «Сохранить» и «Отмена»
|
||||
>
|
||||
> 5. При сохранении — вызов соответствующего метода сервиса (create/update)
|
||||
> 6. При удалении — confirm dialog → вызов deleteMilestone
|
||||
> 7. После любого изменения — обновить список вех и обновить данные на канвасе (через сервис/GanttStore)
|
||||
>
|
||||
> 8. Инжектировать GanttMilestoneService и NgbModal
|
||||
|
||||
#### Этап FE-3: Отображение вех на таймлайне (Konva canvas)
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Отобрази вехи (milestones) на canvas-диаграмме Ганта.
|
||||
>
|
||||
> Проект использует Konva.js для рендеринга.
|
||||
>
|
||||
> 1. При загрузке данных диаграммы — извлечь массив `milestones`
|
||||
>
|
||||
> 2. Для каждой вехи нарисовать на canvas:
|
||||
> - **Маркер-флажок** (flag marker) на позиции даты вехи на шкале времени
|
||||
> - Стиль: маленький треугольник или флаг на верхней границе таймлайна
|
||||
> - Цвет = milestone.color
|
||||
> - **Пунктирная вертикальная линия** от маркера вниз через все секции (на всю высоту области секций)
|
||||
> - Стиль: Konva.Line, dash: [5, 5], strokeWidth: 1.5, color = milestone.color, opacity: 0.7
|
||||
>
|
||||
> 3. **Позиционирование:**
|
||||
> - Рассчитать X-позицию по дате вехи с использованием текущей шкалы времени (как для задач)
|
||||
> - Если дата вехи выходит за текущий диапазон — расширить диапазон (влияет на общую ширину таймлайна)
|
||||
> - При изменении масштаба (days/weeks/months/quarters) — пересчитать позиции всех вех
|
||||
> - При скролле — вехи скроллятся вместе с задачами
|
||||
>
|
||||
> 4. **Tooltip при наведении:**
|
||||
> - При наведении на маркер или пунктирную линию — показать Konva.Tooltip или HTML tooltip:
|
||||
> - Если веха одна на эту дату — показать название
|
||||
> - Если несколько вех на одну дату — показать список: цветной кружок + название для каждой
|
||||
>
|
||||
> 5. **Z-index:**
|
||||
> - Линии вех рисуются **поверх** фона секций, но **под** задачами
|
||||
> - Маркеры-флажки — поверх всего (самый высокий z-index)
|
||||
>
|
||||
> 6. **Реактивность:**
|
||||
> - При добавлении/удалении/редактировании вехи — перерисовать только вехи (без полного ре рендера задач)
|
||||
> - Подписаться на изменения массива milestones через сервис/store
|
||||
|
||||
---
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
| # | Этап | Сторона | Зависимости |
|
||||
|---|------|---------|-------------|
|
||||
| 1 | Миграция + Домен | Backend | — |
|
||||
| 2 | CRUD вех | Backend | Этап 1 |
|
||||
| 3 | Включение вех в ответ диаграммы | Backend | Этап 2 |
|
||||
| 4 | Модели + Сервис | Frontend | — (параллельно с backend) |
|
||||
| 5 | Модальное окно управления вехами | Frontend | Этапы 4 + backend 2 |
|
||||
| 6 | Отображение вех на таймлайне | Frontend | Этапы 4 + backend 3 |
|
||||
@@ -0,0 +1,407 @@
|
||||
---
|
||||
title: "Leadera: Ограничения секций в диаграмме Ганта"
|
||||
date: 2026-04-06
|
||||
tags: ["leadera", "gantt", "angular", "go", "api", "ux"]
|
||||
---
|
||||
|
||||
## Бизнес-требования
|
||||
|
||||
### BR-1: Лимит одновременных задач (Concurrency Limit)
|
||||
|
||||
У каждой секции появляется параметр **max_concurrent_tasks** (целое число ≥ 0, по умолчанию 0).
|
||||
|
||||
**Поведение:**
|
||||
|
||||
- Если `max_concurrent_tasks = 3` — в любой данный день в этой секции не может быть больше 3 активных задач одновременно
|
||||
- Если `max_concurrent_tasks = 0` — ограничение отключено, лимита нет
|
||||
- Пересечение считается по дате: задача занимает дни с `start_date` по `end_date` включительно. Две задачи «пересекаются», если у них есть хотя бы один общий день
|
||||
- Ограничение работает **только в рамках одной секции**. Задачи из разных секций не влияют друг на друга
|
||||
|
||||
**Срабатывание при:**
|
||||
|
||||
1. Создании задачи — если добавление нарушает лимит → Alert
|
||||
2. Перетаскивании задачи (drag) — если новая позиция нарушает лимит → Alert
|
||||
3. Растягивании задачи (resize) — если новый размер нарушает лимит → Alert
|
||||
4. Редактировании задачи (форма) — если изменённые даты нарушают лимит → Alert
|
||||
|
||||
**Важно:** Действие **НЕ отменяется**. Задача создаётся/перемещается в любом случае, но пользователь видит предупреждение.
|
||||
|
||||
**Текст Alert:** «В секции "[название секции]" на [дата/диапазон] превышен лимит одновременных задач (максимум: N)»
|
||||
|
||||
---
|
||||
|
||||
### BR-2: Блоки секций (Section Blocks)
|
||||
|
||||
У каждой секции появляется набор **блоков** — особый вид записей, отображаемых на таймлайне как заштрихованные области.
|
||||
|
||||
**Сущность GanttSectionBlock:**
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | UUID | Идентификатор |
|
||||
| section_id | UUID | Принадлежность к секции |
|
||||
| title | string | Название (например: «Отпуск», «Командировка») |
|
||||
| start_date | date | Начало интервала (минимум 1 день) |
|
||||
| end_date | date | Конец интервала |
|
||||
| block_concurrency | boolean | Запрещать ли пересечение обычных задач с данным блоком |
|
||||
|
||||
**Валидация:**
|
||||
|
||||
- `end_date` не может быть раньше `start_date`
|
||||
- Все поля обязательны
|
||||
- Блок может быть в прошлом
|
||||
|
||||
**Визуальное отображение на Ганте:**
|
||||
|
||||
- Заштрихованная область на **всю высоту секции** (включая все строки задач в секции)
|
||||
- Название блока отображается **поверх штриховки с подложкой** (для читаемости)
|
||||
|
||||
**Поведение block_concurrency:**
|
||||
|
||||
- Если `block_concurrency = true` — при пересечении задачи с данным блоком показывается Alert (аналогично BR-1), но задача **всё равно создаётся/перемещается**
|
||||
- Если `block_concurrency = false` — блок отображается визуально, но не влияет на задачи
|
||||
|
||||
**Срабатывание при:**
|
||||
|
||||
1. Создании задачи — пересекает блок с `block_concurrency = true` → Alert
|
||||
2. Перетаскивании задачи (drag) → Alert
|
||||
3. Растягивании задачи (resize) → Alert
|
||||
4. Редактировании задачи (форма) → Alert
|
||||
|
||||
**Текст Alert:** «В секции "[название секции]" задача пересекает блок "[название блока]" ([дата] — [дата])»
|
||||
|
||||
---
|
||||
|
||||
### BR-3: Система Alert-ов
|
||||
|
||||
Все предупреждения (от BR-1 и BR-2) выводятся в **единую область** над диаграммой Ганта.
|
||||
|
||||
**Поведение:**
|
||||
|
||||
- Alert-ы — Bootstrap Alert (warning), **постоянные**
|
||||
- Пользователь **НЕ может** закрыть Alert вручную
|
||||
- Alert исчезает **автоматически**, когда текущая дата прошла дату конфликта (конфликт больше не актуален)
|
||||
- Проверка конфликтов — **только на фронтенде** (на основе загруженных данных: задачи секции, блоки, max_concurrent_tasks)
|
||||
- Если Alert-ов много — область **сворачиваемая** с счётчиком в бейдже (например: «⚠️ 3 предупреждения»)
|
||||
- По умолчанию — развёрнуто
|
||||
- Сворачивание/разворачивание — клик по заголовку области
|
||||
|
||||
---
|
||||
|
||||
### BR-4: Настройки секции
|
||||
|
||||
- Настройки совмещены с **существующим модальным окном редактирования секции** (не отдельное окно)
|
||||
|
||||
**Окно создания секции** — добавляется:
|
||||
- Поле «Макс. одновременных задач» (числовой ввод, 0 = без лимита, по умолчанию 0)
|
||||
|
||||
**Окно редактирования секции** — добавляется:
|
||||
- Поле «Макс. одновременных задач» (числовой ввод, 0 = без лимита)
|
||||
- Список блоков (таблица/список с CRUD: добавить, редактировать, удалить)
|
||||
- **Сортировка:** по `start_date` по убыванию (будущее сверху)
|
||||
- Каждый блок — форма: title, start_date, end_date, block_concurrency (чекбокс: «Запретить пересечение задач»)
|
||||
- Валидация: end_date >= start_date, все поля заполнены
|
||||
|
||||
---
|
||||
|
||||
### BR-5: Наследование
|
||||
|
||||
- При появлении функционала копирования/дублирования диаграммы — настройки секций (лимит + блоки) наследуются
|
||||
|
||||
---
|
||||
|
||||
## Технические требования
|
||||
|
||||
### База данных
|
||||
|
||||
#### Новая таблица: gantt_section_blocks
|
||||
|
||||
```sql
|
||||
CREATE TABLE gantt_section_blocks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
section_id UUID NOT NULL REFERENCES gantt_sections(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
block_concurrency BOOLEAN NOT NULL DEFAULT false,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
updated_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT chk_block_dates CHECK (end_date >= start_date)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_gantt_section_blocks_section ON gantt_section_blocks(section_id);
|
||||
CREATE INDEX idx_gantt_section_blocks_dates ON gantt_section_blocks(start_date, end_date);
|
||||
```
|
||||
|
||||
#### Изменение таблицы: gantt_sections
|
||||
|
||||
```sql
|
||||
ALTER TABLE gantt_sections ADD COLUMN max_concurrent_tasks INTEGER NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Backend (Go API)
|
||||
|
||||
#### Этап 1: Миграция + Домен
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Добавь в проект новую таблицу `gantt_section_blocks` и колонку `max_concurrent_tasks` в `gantt_sections`.
|
||||
>
|
||||
> 1. Создай миграцию в `cmd/migrate/`:
|
||||
> - Новая таблица `gantt_section_blocks` с полями: id (UUID PK), section_id (UUID FK → gantt_sections, ON DELETE CASCADE), title (VARCHAR 255 NOT NULL), start_date (DATE NOT NULL), end_date (DATE NOT NULL), block_concurrency (BOOLEAN DEFAULT false), created_by (UUID FK → users), updated_by (UUID FK → users), created_at, updated_at, deleted_at. CHECK constraint: end_date >= start_date. Индексы по section_id и (start_date, end_date).
|
||||
> - ALTER TABLE gantt_sections ADD COLUMN max_concurrent_tasks INTEGER NOT NULL DEFAULT 0.
|
||||
> 2. Создай доменную модель `GanttSectionBlock` в `internal/domain/gantt.go` со всеми полями таблицы.
|
||||
> 3. Добавь поле `MaxConcurrentTasks int` в структуру `GanttSection`.
|
||||
> 4. Обнови маппинг в relevant repository файлах, чтобы новое поле `max_concurrent_tasks` читалось/писалось.
|
||||
|
||||
#### Этап 2: CRUD для GanttSectionBlocks
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Реализуй полный CRUD для сущности `GanttSectionBlock` по аналогии с существующими handlers/services/repositories в проекте.
|
||||
>
|
||||
> **Роуты (внутри группы секции):**
|
||||
> - `GET /api/gantt-charts/:chartId/sections/:sectionId/blocks` — список блоков секции
|
||||
> - `POST /api/gantt-charts/:chartId/sections/:sectionId/blocks` — создать блок
|
||||
> - `PUT /api/gantt-charts/:chartId/sections/:sectionId/blocks/:blockId` — обновить блок
|
||||
> - `DELETE /api/gantt-charts/:chartId/sections/:sectionId/blocks/:blockId` — удалить блок (soft delete)
|
||||
>
|
||||
> **Repository:** `internal/repository/gantt_section_block_repository.go`
|
||||
> - `GetBySectionID(ctx, sectionID) ([]domain.GanttSectionBlock, error)`
|
||||
> - `GetByID(ctx, id) (*domain.GanttSectionBlock, error)`
|
||||
> - `Create(ctx, block) error`
|
||||
> - `Update(ctx, block) error`
|
||||
> - `Delete(ctx, id) error` — soft delete (set deleted_at)
|
||||
> - Все запросы — raw SQL через pgx, как в остальных репозиториях проекта
|
||||
>
|
||||
> **Service:** `internal/service/gantt_section_block_service.go`
|
||||
> - Бизнес-логика: валидация dates (end >= start), проверка прав доступа к секции
|
||||
> - Inject repository
|
||||
>
|
||||
> **Handler:** `internal/handler/gantt_section_block_handler.go`
|
||||
> - Swagger-аннотации для всех эндпоинтов
|
||||
> - Валидация входных данных
|
||||
> - Ошибки в формате проекта (GanttErrorResponse)
|
||||
>
|
||||
> **Модели запросов:**
|
||||
> - Create: title (required), start_date (required), end_date (required), block_concurrency (bool, default false)
|
||||
> - Update: title, start_date, end_date, block_concurrency — все опциональны
|
||||
|
||||
#### Этап 3: Обновление настроек секции
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Обнови CRUD секций (gantt_sections) для поддержки нового поля `max_concurrent_tasks`.
|
||||
>
|
||||
> 1. Добавь `max_concurrent_tasks` в `GanttSectionCreateRequest` (опциональное, int ≥ 0, default 0)
|
||||
> 2. Добавь `max_concurrent_tasks` в `GanttSectionUpdateRequest` (опциональное, int ≥ 0)
|
||||
> 3. Обнови SQL-запросы в repository: INSERT и UPDATE должны включать `max_concurrent_tasks`
|
||||
> 4. При GET секции (или списке секций) — возвращать `max_concurrent_tasks` в ответе
|
||||
> 5. Валидация: значение должно быть ≥ 0
|
||||
|
||||
#### Этап 4: Включение блоков в ответ секций
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> При запросе списка секций или одной секции включай связанные блоки в ответ.
|
||||
>
|
||||
> 1. Обнови `GET /api/gantt-charts/:chartId/sections/:sectionId` — добавить поле `blocks` с массивом GanttSectionBlock
|
||||
> 2. Обнови `GET /api/gantt-charts/:chartId/sections` — добавить поле `blocks` для каждой секции
|
||||
> 3. Блоки с `deleted_at IS NOT NULL` не возвращаются
|
||||
> 4. Блоки сортируются по start_date по убыванию (будущее сверху)
|
||||
|
||||
---
|
||||
|
||||
### Frontend (Angular)
|
||||
|
||||
#### Этап FE-1: Модели + Сервис
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Добавь в проект поддержку ограничений секций Ганта.
|
||||
>
|
||||
> 1. В `pages/gantt/models/gantt-chart.model.ts` добавь:
|
||||
>
|
||||
> ```typescript
|
||||
> export interface GanttSectionBlock {
|
||||
> id: string;
|
||||
> section_id: string;
|
||||
> title: string;
|
||||
> start_date: string;
|
||||
> end_date: string;
|
||||
> block_concurrency: boolean;
|
||||
> created_by_user: UserInfo;
|
||||
> updated_by_user: UserInfo;
|
||||
> created_at: string;
|
||||
> updated_at: string;
|
||||
> }
|
||||
>
|
||||
> export interface GanttSectionBlockCreateRequest {
|
||||
> title: string;
|
||||
> start_date: string;
|
||||
> end_date: string;
|
||||
> block_concurrency?: boolean;
|
||||
> }
|
||||
>
|
||||
> export interface GanttSectionBlockUpdateRequest {
|
||||
> title?: string;
|
||||
> start_date?: string;
|
||||
> end_date?: string;
|
||||
> block_concurrency?: boolean;
|
||||
> }
|
||||
>
|
||||
> export interface ConflictCheck {
|
||||
> has_conflicts: boolean;
|
||||
> conflicts: ConflictItem[];
|
||||
> }
|
||||
>
|
||||
> export interface ConflictItem {
|
||||
> type: 'concurrency' | 'block';
|
||||
> section_id: string;
|
||||
> section_title: string;
|
||||
> block_title?: string;
|
||||
> start_date?: string;
|
||||
> end_date?: string;
|
||||
> message: string;
|
||||
> dates?: string[];
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 2. Добавь `max_concurrent_tasks: number` в интерфейс `GanttSection`
|
||||
> 3. Добавь `blocks: GanttSectionBlock[]` в интерфейс `GanttSection`
|
||||
> 4. Добавь `max_concurrent_tasks?: number` в `GanttSectionCreateRequest` и `GanttSectionUpdateRequest`
|
||||
>
|
||||
> 5. Создай сервис `pages/gantt/services/gantt-section-block.service.ts`:
|
||||
> - Inject HttpClient
|
||||
> - Методы: getBlocks(chartId, sectionId), createBlock(chartId, sectionId, data), updateBlock(chartId, sectionId, blockId, data), deleteBlock(chartId, sectionId, blockId)
|
||||
> - Все методы возвращают Observable с типизированными ответами (по аналогии с GanttChartService)
|
||||
|
||||
#### Этап FE-2: Интеграция в модальные окна секции
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Интегрируй настройки ограничений в существующие модальные окна создания и редактирования секции Ганта.
|
||||
>
|
||||
> 1. Найди существующие компоненты модальных окон секции (создание/редактирование).
|
||||
>
|
||||
> **Окно создания секции** — добавить:
|
||||
> - Числовой ввод: «Максимум одновременных задач»
|
||||
> - Подсказка: «0 = без ограничений»
|
||||
> - Валидация: число ≥ 0
|
||||
> - Значение по умолчанию: 0
|
||||
> - Отправляется в `GanttSectionCreateRequest` как `max_concurrent_tasks`
|
||||
>
|
||||
> **Окно редактирования секции** — добавить:
|
||||
> - Числовой ввод: «Максимум одновременных задач» (аналогично созданию)
|
||||
> - Секция «Блоки (запретные интервалы)»:
|
||||
> - Таблица/список существующих блоков с колонками: Название, Начало, Конец, Блокировка (чекбокс/иконка), Действия (редактировать/удалить)
|
||||
> - **Сортировка:** по `start_date` по убыванию (будущее сверху)
|
||||
> - Кнопка «Добавить блок» — inline-форма:
|
||||
> - title (текстовый ввод, обязательный)
|
||||
> - start_date (datepicker, обязательный)
|
||||
> - end_date (datepicker, обязательный)
|
||||
> - block_concurrency (чекбокс: «Запретить пересечение задач»)
|
||||
> - Валидация: end_date >= start_date, все поля заполнены
|
||||
> - Редактирование — inline или через ту же форму
|
||||
> - Удаление — с подтверждением
|
||||
>
|
||||
> 2. При открытии окна редактирования — загрузить текущие настройки секции + блоки
|
||||
> 3. При сохранении — обновить max_concurrent_tasks и сохранить изменения блоков
|
||||
> 4. Инжектировать GanttChartService и GanttSectionBlockService
|
||||
|
||||
#### Этап FE-3: Визуализация блоков на Ганте
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Отобрази блоки секций (GanttSectionBlock) на canvas-диаграмме Ганта.
|
||||
>
|
||||
> Проект использует Konva.js для рендеринга. Основной компонент: `gantt-chart-canvas`.
|
||||
>
|
||||
> 1. При загрузке данных диаграммы — извлечь блоки из каждой секции
|
||||
> 2. В рендерере секций (canvas/timeline-renderer.ts или аналогичный) — для каждого блока:
|
||||
> - Нарисовать заштрихованную область (diagonal hatch pattern) на **всю высоту секции** (все строки задач в этой секции)
|
||||
> - Область ограничена по горизонтали: от start_date до end_date (в пикселях по шкале времени)
|
||||
> - Цвет штриховки: полупрозрачный серый или configurable
|
||||
> - Поверх штриховки — текст с названием блока (block.title) на подложке (прямоугольник с фоном, чтобы текст читался)
|
||||
> 3. Блоки рендерятся **до** задач (z-index ниже), чтобы задачи были поверх
|
||||
> 4. Блоки с `block_concurrency = true` и `false` визуально не отличаются (штриховка одинаковая)
|
||||
> 5. При скролле/зуме — блоки пересчитываются как обычные элементы таймлайна
|
||||
|
||||
#### Этап FE-4: Система Alert-ов
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Создай компонент для отображения Alert-ов о конфликтах на диаграмме Ганта.
|
||||
>
|
||||
> 1. Создай компонент `pages/gantt/components/gantt-conflict-alerts/`
|
||||
>
|
||||
> 2. Компонент размещается **над** canvas-диаграммой Ганта
|
||||
>
|
||||
> 3. Входные данные: массив `ConflictItem[]` (из модели)
|
||||
>
|
||||
> 4. Визуал:
|
||||
> - Bootstrap Alert (warning) для каждого конфликта
|
||||
> - Каждый Alert показывает message из ConflictItem
|
||||
> - **Без кнопки закрытия** — Alert нельзя закрыть вручную
|
||||
> - Alert исчезает автоматически, когда текущая дата прошла дату конфликта
|
||||
> - Область **сворачиваемая**: заголовок «⚠️ Предупреждения (N)» — клик сворачивает/разворачивает список
|
||||
> - По умолчанию — развёрнуто
|
||||
> - При 0 конфликтов — область скрыта
|
||||
>
|
||||
> 5. Состояние хранится в сервисе или в компоненте диаграммы:
|
||||
> - Добавление нового конфликта → area auto-expands
|
||||
> - Alert удаляется из массива **автоматически**, когда текущая дата прошла дату конфликта
|
||||
> - Проверка актуальности конфликтов — при каждой отрисовке / изменении данных
|
||||
> - Не использовать toast — Alert-ы постоянные
|
||||
|
||||
#### Этап FE-5: Проверка конфликтов при операциях с задачами
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Интегрируй проверку конфликтов во все операции с задачами на диаграмме Ганта.
|
||||
>
|
||||
> Проект использует interaction-handler.ts для drag/resize и формы для создания/редактирования.
|
||||
>
|
||||
> **Важно:** Проверка конфликтов — **только на фронтенде**, без вызова API. Используются загруженные данные секции (задачи, блоки, max_concurrent_tasks).
|
||||
>
|
||||
> 1. Вынеси логику проверки конфликтов `checkConflicts()` в отдельный метод в gantt-canvas.service.ts:
|
||||
> - Принимает: section (GanttSection — содержит max_concurrent_tasks и blocks), tasks (GanttTask[] — задачи секции), task_id (nullable — исключить текущую задачу), start_date, end_date
|
||||
> - Логика:
|
||||
> a. Если section.max_concurrent_tasks > 0: посчитать кол-во задач (исключая task_id), пересекающихся с [start_date, end_date] на каждый день. Если в любой день count > max_concurrent_tasks — добавить конфликт concurrency
|
||||
> b. Для каждого блока секции с block_concurrency = true, пересекающегося с [start_date, end_date] — добавить конфликт block
|
||||
> - Возвращает ConflictCheck
|
||||
>
|
||||
> 2. После **каждой** успешной операции с задачей (создание, обновление, drag, resize):
|
||||
> - Вызвать checkConflicts с параметрами задачи
|
||||
> - Если has_conflicts = true — передать конфликты в GanttConflictAlertsComponent
|
||||
> - Alert-ы показываются, действие **НЕ отменяется**
|
||||
>
|
||||
> 3. Точки интеграции:
|
||||
> - **Создание задачи** (форма): после успешного POST → checkConflicts → show alerts
|
||||
> - **Редактирование задачи** (форма): после успешного PUT → checkConflicts → show alerts
|
||||
> - **Drag задачи** (interaction-handler.ts): после успешного обновления позиции → checkConflicts → show alerts
|
||||
> - **Resize задачи** (interaction-handler.ts): после успешного обновления размера → checkConflicts → show alerts
|
||||
>
|
||||
> 4. При переключении диаграммы или секции — очищать Alert-ы
|
||||
> 5. Alert-ы автоматически удаляются, когда текущая дата прошла дату конфликта — область скрывается, когда конфликтов не осталось
|
||||
|
||||
---
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
| # | Этап | Сторона | Зависимости |
|
||||
|---|------|---------|-------------|
|
||||
| 1 | Миграция + Домен | Backend | — |
|
||||
| 2 | CRUD блоков | Backend | Этап 1 |
|
||||
| 3 | Обновление настроек секции | Backend | Этап 1 |
|
||||
| 4 | Включение блоков в ответ секций | Backend | Этап 2 |
|
||||
| 5 | Модели + Сервис | Frontend | — (можно параллельно с backend) |
|
||||
| 6 | Интеграция в модальные окна | Frontend | Этапы 5 + backend 2,3 |
|
||||
| 7 | Визуализация блоков на Ганте | Frontend | Этапы 5 + backend 4 |
|
||||
| 8 | Система Alert-ов | Frontend | Этап 5 |
|
||||
| 9 | Проверка конфликтов при операциях | Frontend | Этапы 5,7,8 |
|
||||
@@ -0,0 +1,746 @@
|
||||
---
|
||||
title: "Leadera — WebSocket для диаграмм Ганта"
|
||||
date: 2026-04-07
|
||||
lastmod: 2026-04-07
|
||||
tags: ["leadera", "websocket", "realtime", "gantt", "architecture"]
|
||||
weight: 110
|
||||
---
|
||||
|
||||
# WebSocket для диаграмм Ганта — realtime-совместное редактирование
|
||||
|
||||
**Дата:** 07.04.2026
|
||||
**Статус:** Планирование
|
||||
**Проект:** Leadera (dev — sand.a2v.space)
|
||||
|
||||
---
|
||||
|
||||
## 1. Постановка задачи
|
||||
|
||||
Диаграммы Ганта должны обновляться в реальном времени при совместной работе: если два или более пользователей редактируют один Гант, изменения каждого мгновенно отображаются у остальных — без перезагрузки страницы и ручного обновления.
|
||||
|
||||
---
|
||||
|
||||
## 2. Архитектура
|
||||
|
||||
```
|
||||
Angular frontend ←── WebSocket ──→ Go backend (Hub) ──→ PostgreSQL
|
||||
│ │
|
||||
│ HTTP (мутации) │ Broadcast
|
||||
└──→ REST API ──→ Service ──→ DB ──→ Hub ──→ все клиенты комнаты
|
||||
```
|
||||
|
||||
**Ключевой принцип:** мутации данных идут через HTTP (REST), WebSocket используется **только для доставки уведомлений**. Сервер — источник истины, клиент не может обойти проверки через WS.
|
||||
|
||||
### Комнаты
|
||||
|
||||
Каждый открытый Гант — это «комната» с идентификатором `chartId`. При подключении клиент подписывается на комнату. Hub рассылает события всем подписчикам комнаты.
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend (Go)
|
||||
|
||||
### 3.1. Зависимости
|
||||
|
||||
```bash
|
||||
go get github.com/gorilla/websocket
|
||||
```
|
||||
|
||||
### 3.2. Новые файлы — `internal/ws/`
|
||||
|
||||
#### `message.go` — типы сообщений
|
||||
|
||||
```go
|
||||
package ws
|
||||
|
||||
import "time"
|
||||
|
||||
// EventType — тип события
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
// Данные
|
||||
EventSectionCreated EventType = "section_created"
|
||||
EventSectionUpdated EventType = "section_updated"
|
||||
EventSectionDeleted EventType = "section_deleted"
|
||||
EventSectionsReordered EventType = "sections_reordered"
|
||||
EventTaskCreated EventType = "task_created"
|
||||
EventTaskUpdated EventType = "task_updated"
|
||||
EventTaskDeleted EventType = "task_deleted"
|
||||
|
||||
// Присутствие
|
||||
EventUserJoined EventType = "user_joined"
|
||||
EventUserLeft EventType = "user_left"
|
||||
|
||||
// Безопасность / доступ
|
||||
EventAccessRevoked EventType = "access_revoked"
|
||||
EventChartArchived EventType = "chart_archived"
|
||||
EventRoleChanged EventType = "role_changed"
|
||||
EventTokenExpired EventType = "token_expired"
|
||||
)
|
||||
|
||||
// Event — WS-событие, отправляемое клиентам
|
||||
type Event struct {
|
||||
Type EventType `json:"type"`
|
||||
ChartID string `json:"chartId"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Payload interface{} `json:"payload,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// IncomingMessage — сообщение от клиента (для ping/pong)
|
||||
type IncomingMessage struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
```
|
||||
|
||||
#### `hub.go` — центральный брокер
|
||||
|
||||
```go
|
||||
package ws
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Hub управляет комнатами и рассылкой событий
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
rooms map[string]map[*Client]bool // chartId → клиенты
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
rooms: make(map[string]map[*Client]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe добавляет клиента в комнату
|
||||
func (h *Hub) Subscribe(chartID string, client *Client) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if h.rooms[chartID] == nil {
|
||||
h.rooms[chartID] = make(map[*Client]bool)
|
||||
}
|
||||
h.rooms[chartID][client] = true
|
||||
|
||||
// Уведомить остальных: user_joined
|
||||
h.broadcastExcept(chartID, client, Event{
|
||||
Type: EventUserJoined,
|
||||
ChartID: chartID,
|
||||
UserID: client.UserID,
|
||||
Payload: map[string]string{"username": client.Username},
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// Unsubscribe удаляет клиента из комнаты
|
||||
func (h *Hub) Unsubscribe(chartID string, client *Client) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if clients, ok := h.rooms[chartID]; ok {
|
||||
delete(clients, client)
|
||||
if len(clients) == 0 {
|
||||
delete(h.rooms, chartID)
|
||||
}
|
||||
}
|
||||
|
||||
// Уведомить остальных: user_left
|
||||
h.broadcastExcept(chartID, client, Event{
|
||||
Type: EventUserLeft,
|
||||
ChartID: chartID,
|
||||
UserID: client.UserID,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// Broadcast рассылает событие всем клиентам в комнате
|
||||
func (h *Hub) Broadcast(chartID string, event Event) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
if clients, ok := h.rooms[chartID]; ok {
|
||||
for client := range clients {
|
||||
client.Send(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendToUser отправляет событие конкретному пользователю во всех его комнатах
|
||||
func (h *Hub) SendToUser(userID string, event Event) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
for _, clients := range h.rooms {
|
||||
for client := range clients {
|
||||
if client.UserID == userID {
|
||||
client.Send(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastExcept рассылает всем, кроме отправителя
|
||||
func (h *Hub) broadcastExcept(chartID string, sender *Client, event Event) {
|
||||
if clients, ok := h.rooms[chartID]; ok {
|
||||
for client := range clients {
|
||||
if client != sender {
|
||||
client.Send(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `client.go` — обёртка WS-соединения
|
||||
|
||||
```go
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
maxMessageSize = 1024
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Hub *Hub
|
||||
Conn *websocket.Conn
|
||||
UserID string
|
||||
Username string
|
||||
ChartID string
|
||||
SendCh chan Event
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewClient(hub *Hub, conn *websocket.Conn, userID, username, chartID string) *Client {
|
||||
return &Client{
|
||||
Hub: hub,
|
||||
Conn: conn,
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
ChartID: chartID,
|
||||
SendCh: make(chan Event, 64),
|
||||
}
|
||||
}
|
||||
|
||||
// ReadPump читает входящие сообщения (ping/pong)
|
||||
func (c *Client) ReadPump() {
|
||||
defer func() {
|
||||
c.Hub.Unsubscribe(c.ChartID, c)
|
||||
c.Conn.Close()
|
||||
}()
|
||||
|
||||
c.Conn.SetReadLimit(maxMessageSize)
|
||||
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
c.Conn.SetPongHandler(func(string) error {
|
||||
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
_, message, err := c.Conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
// Клиент может слать только ping, данные игнорируем
|
||||
_ = message
|
||||
}
|
||||
}
|
||||
|
||||
// WritePump отправляет события клиенту
|
||||
func (c *Client) WritePump() {
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
c.Conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-c.SendCh:
|
||||
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if !ok {
|
||||
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(event)
|
||||
c.mu.Lock()
|
||||
c.Conn.WriteMessage(websocket.TextMessage, data)
|
||||
c.mu.Unlock()
|
||||
|
||||
case <-ticker.C:
|
||||
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send ставит событие в очередь отправки
|
||||
func (c *Client) Send(event Event) {
|
||||
select {
|
||||
case c.SendCh <- event:
|
||||
default:
|
||||
// Очередь полна — отключаем медленного клиента
|
||||
close(c.SendCh)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3. WS endpoint — `internal/handlers/ws_handler.go`
|
||||
|
||||
```go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"app-leadera-api/internal/middleware"
|
||||
"app-leadera-api/internal/ws"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool { return true }, // nginx проксирует
|
||||
}
|
||||
|
||||
type WSHandler struct {
|
||||
hub *ws.Hub
|
||||
ganttMW *middleware.GanttMiddleware
|
||||
}
|
||||
|
||||
func NewWSHandler(hub *ws.Hub, ganttMW *middleware.GanttMiddleware) *WSHandler {
|
||||
return &WSHandler{hub: hub, ganttMW: ganttMW}
|
||||
}
|
||||
|
||||
func (h *WSHandler) HandleGanttWS(c *gin.Context) {
|
||||
// 1. Получить пользователя из контекста (auth middleware уже отработал)
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Проверить доступ к chartId (gantt middleware)
|
||||
chartID := c.Param("chartId")
|
||||
if _, err := uuid.Parse(chartID); err != nil {
|
||||
c.AbortWithStatusJSON(400, gin.H{"error": "invalid chart id"})
|
||||
return
|
||||
}
|
||||
// TODO: проверить через ganttMW что userID имеет доступ к этому chart
|
||||
|
||||
// 3. Upgrade до WebSocket
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Создать клиент и подписать на комнату
|
||||
client := ws.NewClient(
|
||||
h.hub, conn,
|
||||
userID.(string),
|
||||
c.GetString("username"),
|
||||
chartID,
|
||||
)
|
||||
h.hub.Subscribe(chartID, client)
|
||||
|
||||
// 5. Запустить pump'ы
|
||||
go client.WritePump()
|
||||
go client.ReadPump()
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4. Регистрация маршрута
|
||||
|
||||
В `cmd/server/main.go` или где регистрируются routes:
|
||||
|
||||
```go
|
||||
// После создания hub
|
||||
wsHub := ws.NewHub()
|
||||
|
||||
// WS endpoint
|
||||
wsHandler := handlers.NewWSHandler(wsHub, ganttMW)
|
||||
api.GET("/ws/gantt/:chartId", authMW.RequireAuth(), wsHandler.HandleGanttWS)
|
||||
|
||||
// Передать hub в ganttHandler для broadcast'ов
|
||||
ganttHandler := handlers.NewGanttHandler(ganttService, sectionService, taskService, blockService, wsHub)
|
||||
```
|
||||
|
||||
### 3.5. Интеграция broadcast в handlers
|
||||
|
||||
В каждый write-handler — одна строка после успешной операции:
|
||||
|
||||
```go
|
||||
// Пример: после успешного UpdateTask
|
||||
h.hub.Broadcast(chartID, ws.Event{
|
||||
Type: ws.EventTaskUpdated,
|
||||
ChartID: chartID,
|
||||
UserID: userID,
|
||||
Payload: updatedTask,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
```
|
||||
|
||||
Точки интеграции в `gantt_handler.go`:
|
||||
- `CreateGanttChart` → не нужен (пользователь только что создал, он один)
|
||||
- `UpdateGanttChart` / `ArchiveGanttChart` → `chart_archived`
|
||||
- `CreateSection` → `section_created`
|
||||
- `UpdateSection` → `section_updated`
|
||||
- `DeleteSection` → `section_deleted`
|
||||
- `ReorderSections` → `sections_reordered`
|
||||
- `CreateTask` → `task_created`
|
||||
- `UpdateTask` → `task_updated`
|
||||
- `DeleteTask` → `task_deleted`
|
||||
|
||||
### 3.6. Интеграция событий безопасности
|
||||
|
||||
В соответствующих service-методах:
|
||||
|
||||
```go
|
||||
// При исключении из space (SpaceService.RemoveMember)
|
||||
wsHub.SendToUser(removedUserID, ws.Event{
|
||||
Type: ws.EventAccessRevoked,
|
||||
Payload: map[string]string{"spaceId": spaceID},
|
||||
})
|
||||
|
||||
// При архивации ганта
|
||||
wsHub.Broadcast(chartID, ws.Event{
|
||||
Type: ws.EventChartArchived,
|
||||
ChartID: chartID,
|
||||
})
|
||||
|
||||
// При изменении роли
|
||||
wsHub.SendToUser(userID, ws.Event{
|
||||
Type: ws.EventRoleChanged,
|
||||
Payload: map[string]string{"role": newRole},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend (Angular)
|
||||
|
||||
### 4.1. WebSocket сервис — `gantt-ws.service.ts`
|
||||
|
||||
```typescript
|
||||
// src/app/pages/gantt/services/gantt-ws.service.ts
|
||||
|
||||
import { Injectable, inject, OnDestroy } from '@angular/core';
|
||||
import { Subject, Observable } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { SpaceStorageService } from '../../../core/services/space-storage.service';
|
||||
import { AuthService } from '../../../core/auth/services/auth.service';
|
||||
|
||||
export interface WSEvent {
|
||||
type: string;
|
||||
chartId?: string;
|
||||
userId?: string;
|
||||
payload?: any;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GanttWSService implements OnDestroy {
|
||||
private spaceStorage = inject(SpaceStorageService);
|
||||
private auth = inject(AuthService);
|
||||
|
||||
private socket: WebSocket | null = null;
|
||||
private eventSubject = new Subject<WSEvent>();
|
||||
private reconnectTimer: any;
|
||||
private chartId: string | null = null;
|
||||
|
||||
/** Observable для подписки на события */
|
||||
get events$(): Observable<WSEvent> {
|
||||
return this.eventSubject.asObservable();
|
||||
}
|
||||
|
||||
/** Подключиться к комнате ганта */
|
||||
connect(chartId: string): void {
|
||||
this.disconnect();
|
||||
this.chartId = chartId;
|
||||
|
||||
const token = this.auth.getAccessToken();
|
||||
const wsUrl = this.buildWSUrl(chartId, token);
|
||||
|
||||
this.socket = new WebSocket(wsUrl);
|
||||
|
||||
this.socket.onopen = () => {
|
||||
console.log(`[WS] Connected to gantt ${chartId}`);
|
||||
};
|
||||
|
||||
this.socket.onmessage = (msg) => {
|
||||
try {
|
||||
const event: WSEvent = JSON.parse(msg.data);
|
||||
this.eventSubject.next(event);
|
||||
} catch (e) {
|
||||
console.error('[WS] Parse error', e);
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onclose = (event) => {
|
||||
console.log(`[WS] Closed: ${event.code}`);
|
||||
if (event.code !== 1000) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onerror = () => {
|
||||
this.socket?.close();
|
||||
};
|
||||
}
|
||||
|
||||
/** Отключиться */
|
||||
disconnect(): void {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
if (this.socket) {
|
||||
this.socket.close(1000, 'manual');
|
||||
this.socket = null;
|
||||
}
|
||||
this.chartId = null;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
private buildWSUrl(chartId: string, token: string): string {
|
||||
const base = environment.wsUrl ||
|
||||
location.protocol.replace('http', 'ws') + '//' + location.host;
|
||||
return `${base}/api/v1/ws/gantt/${chartId}?token=${token}`;
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (!this.chartId) return;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
console.log('[WS] Reconnecting...');
|
||||
if (this.chartId) this.connect(this.chartId);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2. Интеграция в gantt-chart-canvas компонент
|
||||
|
||||
```typescript
|
||||
// В gantt-chart-canvas.component.ts
|
||||
|
||||
export class GanttChartCanvasComponent implements OnInit, OnDestroy {
|
||||
private ws = inject(GanttWSService);
|
||||
private onlineUsers: Set<string> = new Set();
|
||||
private wsSubscription: Subscription | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
// Подключиться к WS при загрузке ганта
|
||||
this.ws.connect(this.chartId);
|
||||
this.wsSubscription = this.ws.events$.subscribe(event => {
|
||||
this.handleWSEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.wsSubscription?.unsubscribe();
|
||||
this.ws.disconnect();
|
||||
}
|
||||
|
||||
private handleWSEvent(event: WSEvent): void {
|
||||
// Если это событие от текущего пользователя — пропускаем
|
||||
// (мы уже обновили UI оптимистично или после HTTP-ответа)
|
||||
if (event.userId === this.currentUserId) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'section_created':
|
||||
this.addSectionLocal(event.payload);
|
||||
this.redrawCanvas();
|
||||
break;
|
||||
|
||||
case 'section_updated':
|
||||
this.updateSectionLocal(event.payload);
|
||||
this.redrawCanvas();
|
||||
break;
|
||||
|
||||
case 'section_deleted':
|
||||
this.removeSectionLocal(event.payload.id);
|
||||
this.redrawCanvas();
|
||||
break;
|
||||
|
||||
case 'sections_reordered':
|
||||
this.reorderSectionsLocal(event.payload.ordered_ids);
|
||||
this.redrawCanvas();
|
||||
break;
|
||||
|
||||
case 'task_created':
|
||||
this.addTaskLocal(event.payload);
|
||||
this.redrawCanvas();
|
||||
break;
|
||||
|
||||
case 'task_updated':
|
||||
this.updateTaskLocal(event.payload);
|
||||
this.redrawCanvas();
|
||||
break;
|
||||
|
||||
case 'task_deleted':
|
||||
this.removeTaskLocal(event.payload.id);
|
||||
this.redrawCanvas();
|
||||
break;
|
||||
|
||||
case 'user_joined':
|
||||
this.onlineUsers.add(event.userId!);
|
||||
this.showOnlineIndicator();
|
||||
break;
|
||||
|
||||
case 'user_left':
|
||||
this.onlineUsers.delete(event.userId!);
|
||||
this.showOnlineIndicator();
|
||||
break;
|
||||
|
||||
// === Безопасность ===
|
||||
case 'access_revoked':
|
||||
this.router.navigate(['/gantt'], {
|
||||
state: { alert: 'Доступ к пространству был отозван' }
|
||||
});
|
||||
break;
|
||||
|
||||
case 'chart_archived':
|
||||
this.router.navigate(['/gantt'], {
|
||||
state: { alert: 'Диаграмма была заархивирована' }
|
||||
});
|
||||
break;
|
||||
|
||||
case 'role_changed':
|
||||
this.currentRole = event.payload.role;
|
||||
this.updateEditableState();
|
||||
break;
|
||||
|
||||
case 'token_expired':
|
||||
this.router.navigate(['/auth/login']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private redrawCanvas(): void {
|
||||
// Перерисовать Konva canvas с обновлёнными данными
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3. Индикатор онлайн-пользователей
|
||||
|
||||
Добавить в шаблон canvas-компонента:
|
||||
|
||||
```html
|
||||
<!-- Онлайн-индикатор -->
|
||||
<div class="online-indicator" *ngIf="onlineUsers.size > 0">
|
||||
<span class="pulse-dot"></span>
|
||||
{{ onlineUsers.size }} онлайн
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Безопасность
|
||||
|
||||
### 5.1. Авторизация при подключении
|
||||
|
||||
| Этап | Проверка |
|
||||
|------|----------|
|
||||
| WS Handshake | JWT в query-параметре `?token=...` |
|
||||
| Подписка на комнату | Проверка membership в space |
|
||||
| Каждое broadcast-событие | Hub знает userId каждого клиента |
|
||||
|
||||
### 5.2. Сценарии отзыва доступа
|
||||
|
||||
| Событие | Триггер | Действие на клиенте |
|
||||
|---------|---------|---------------------|
|
||||
| `access_revoked` | Исключение из space | Редирект на список гантов |
|
||||
| `chart_archived` | Архивация ганта | Редирект на список гантов |
|
||||
| `role_changed` | Смена роли (editor→viewer) | Отключить редактирование |
|
||||
| `token_expired` | Токен протух | Редирект на логин |
|
||||
|
||||
### 5.3. Защита от злоупотреблений
|
||||
|
||||
| Угроза | Мера |
|
||||
|--------|------|
|
||||
| Спам WS-сообщений | Rate limit: 30 msg/min на клиента, `ReadLimit` в gorilla/websocket |
|
||||
| Подключение без прав | Проверка space membership при handshake |
|
||||
| Истёкший токен | Ping/pong + периодическая проверка JWT (раз в 5 мин) |
|
||||
| XSS через WS | Payload — JSON, sanitize имён на frontend |
|
||||
| Мутации через WS | WS **только для чтения** (уведомления). Мутации — через HTTP API с полной валидацией |
|
||||
|
||||
### 5.4. Nginx конфигурация
|
||||
|
||||
В конфиг `sand.a2v.space` добавить:
|
||||
|
||||
```nginx
|
||||
# WebSocket проксирование
|
||||
location /api/v1/ws/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400s; # 24h для long-lived WS
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Оценка трудозатрат
|
||||
|
||||
| Компонент | Строки кода | Время |
|
||||
|-----------|-------------|-------|
|
||||
| `internal/ws/` (hub, client, message) | ~200 | 2-3 часа |
|
||||
| `ws_handler.go` (endpoint) | ~60 | 30 мин |
|
||||
| Интеграция broadcast в handlers | ~20 (по строке на endpoint) | 30 мин |
|
||||
| События безопасности в services | ~30 | 30 мин |
|
||||
| `gantt-ws.service.ts` (Angular) | ~80 | 1 час |
|
||||
| Интеграция в canvas-компонент | ~80 | 1-2 часа |
|
||||
| Индикатор онлайн | ~30 | 30 мин |
|
||||
| Nginx конфигурация | ~10 | 10 мин |
|
||||
| Тестирование | — | 2-3 часа |
|
||||
| **Итого** | **~510 строк** | **~1-2 дня** |
|
||||
|
||||
---
|
||||
|
||||
## 7. Порядок реализации
|
||||
|
||||
1. **Backend: `internal/ws/`** — hub, client, message types
|
||||
2. **Backend: WS endpoint** — handler + route
|
||||
3. **Backend: Broadcast в handlers** — интеграция в существующие CRUD-эндпоинты
|
||||
4. **Backend: События безопасности** — access_revoked, chart_archived, role_changed
|
||||
5. **Nginx: WS location** — проксирование WebSocket
|
||||
6. **Frontend: WS service** — подключение, реконнект, парсинг событий
|
||||
7. **Frontend: Интеграция в canvas** — обработка событий, обновление Konva
|
||||
8. **Frontend: Индикатор онлайн** — визуальная обратная связь
|
||||
9. **Тестирование** — многопользовательский сценарий, отзыв прав, обрывы связи
|
||||
|
||||
---
|
||||
|
||||
## 8. Дальнейшие улучшения (после MVP)
|
||||
|
||||
- **Cursor других пользователей** — показывать позицию курсора на таймлайне
|
||||
- **Блокировка элемента** — «кто-то редактирует эту задачу» (optimistic locking через WS)
|
||||
- **Чат в ганте** — комментарии к задачам прямо на канвасе
|
||||
- **History/undo** — откат изменений с визуальной историей
|
||||
- **Уведомления** — push-уведомления при @mention в комментариях к задачам
|
||||
Reference in New Issue
Block a user