33
.cursor/commands.json
Normal file
33
.cursor/commands.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"name": "init",
|
||||||
|
"description": "Инициализация Play Life: остановка контейнеров, поднятие сервисов, создание дампа с продакшена и восстановление в локальную базу",
|
||||||
|
"command": "./init.sh",
|
||||||
|
"type": "shell",
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "run",
|
||||||
|
"description": "Перезапуск Play Life: перезапуск всех контейнеров",
|
||||||
|
"command": "./run.sh",
|
||||||
|
"type": "shell",
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "backupFromProd",
|
||||||
|
"description": "Создание дампа базы данных с продакшена",
|
||||||
|
"command": "./dump-db.sh",
|
||||||
|
"type": "shell",
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "restoreToLocal",
|
||||||
|
"description": "Восстановление базы данных из самого свежего дампа в локальную базу (автоматически выбирает последний дамп)",
|
||||||
|
"command": "./restore-db.sh",
|
||||||
|
"type": "shell",
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
# План: Изменить сортировку заблокированных желаний по времени разблокировки
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
Изменить сортировку желаний:
|
||||||
|
1. Разблокированные - по цене от меньшего к большему
|
||||||
|
2. Заблокированные без целей-задач - по сроку разблокировки (максимальное время среди проектов)
|
||||||
|
3. Заблокированные с целями-задачами - по сроку разблокировки (максимальное время среди проектов)
|
||||||
|
|
||||||
|
## Статус реализации
|
||||||
|
|
||||||
|
**Уже реализовано:**
|
||||||
|
- ✅ `calculateProjectUnlockWeeks` - функция расчета недель разблокировки
|
||||||
|
- ✅ `calculateLockedSortValue` - использует `calculateProjectUnlockWeeks` и возвращает недели
|
||||||
|
- ✅ `getProjectMedian` - упрощенная версия без fallback (используется как есть)
|
||||||
|
|
||||||
|
**Требуется реализовать:**
|
||||||
|
- ⏳ Создать миграцию для `projects_median_mv` (миграции нет, но используется в коде)
|
||||||
|
- ⏳ В `getWishlistHandler`: заменить `calculateUnlockedSortValue` на прямую сортировку по цене для разблокированных
|
||||||
|
- ⏳ В `getWishlistHandler`: разделить заблокированные на группы (с задачами/без задач) и сортировать каждую группу
|
||||||
|
- ⏳ В `getBoardItemsHandler`: заменить `calculateUnlockedSortValue` на прямую сортировку по цене для разблокированных
|
||||||
|
- ⏳ В `getBoardItemsHandler`: разделить заблокированные на группы (с задачами/без задач) и сортировать каждую группу
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
### 1. Создать миграцию для projects_median_mv
|
||||||
|
|
||||||
|
**Статус:** `getProjectMedian` уже использует `projects_median_mv`, но миграции для неё нет в списке миграций. Нужно создать миграцию.
|
||||||
|
|
||||||
|
**Файл:** `play-life-backend/migrations/000007_add_projects_median_mv.up.sql`
|
||||||
|
|
||||||
|
Убедиться, что materialized view включает `user_id`:
|
||||||
|
```sql
|
||||||
|
CREATE MATERIALIZED VIEW projects_median_mv AS
|
||||||
|
SELECT
|
||||||
|
p.id AS project_id,
|
||||||
|
p.user_id,
|
||||||
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY normalized_total_score) AS median_score
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
project_id,
|
||||||
|
normalized_total_score,
|
||||||
|
report_year,
|
||||||
|
report_week,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
|
||||||
|
FROM weekly_report_mv
|
||||||
|
WHERE
|
||||||
|
(report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
|
||||||
|
OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||||
|
AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
|
||||||
|
) sub
|
||||||
|
JOIN projects p ON p.id = sub.project_id
|
||||||
|
WHERE rn <= 12 AND p.deleted = FALSE
|
||||||
|
GROUP BY p.id, p.user_id
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
CREATE INDEX idx_projects_median_mv_project_id ON projects_median_mv(project_id);
|
||||||
|
CREATE INDEX idx_projects_median_mv_user_id ON projects_median_mv(user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Файл:** `play-life-backend/migrations/000007_add_projects_median_mv.down.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Изменить calculateLockedSortValue для расчета времени
|
||||||
|
|
||||||
|
**Файл:** `play-life-backend/main.go` (строки 12488-12561)
|
||||||
|
|
||||||
|
**Статус:** Функция уже реализована и использует `calculateProjectUnlockWeeks`. Проверить, что логика соответствует требованиям:
|
||||||
|
- Учитывает только условия типа `project_points`
|
||||||
|
- Использует правильного владельца условия (`conditionOwnerID`)
|
||||||
|
- Возвращает максимальное количество недель среди всех условий проектов
|
||||||
|
- Возвращает 999999.0 если нет условий по проектам или все выполнены
|
||||||
|
|
||||||
|
**Текущая реализация уже корректна**, изменения не требуются.
|
||||||
|
|
||||||
|
**Важно:**
|
||||||
|
- Функция уже использует `calculateProjectUnlockWeeks` для расчета (уже реализовано)
|
||||||
|
- Функция НЕ должна учитывать задачи, только проекты. Разделение на группы с задачами и без задач будет в сортировке.
|
||||||
|
- Функция уже правильно обрабатывает владельца условия через `conditionOwnerID` (не использует `userID` напрямую)
|
||||||
|
|
||||||
|
### 3. Обновить сортировку в getWishlistHandler
|
||||||
|
|
||||||
|
**Файл:** `play-life-backend/main.go` (строки 9933-9951)
|
||||||
|
|
||||||
|
**Текущее состояние:**
|
||||||
|
- Разблокированные: используют `calculateUnlockedSortValue` (сумма баллов) - **нужно заменить на цену**
|
||||||
|
- Заблокированные: сортируются по `calculateLockedSortValue` (недели) - **нужно разделить на группы**
|
||||||
|
|
||||||
|
**Изменить:**
|
||||||
|
1. Разблокированные: сортировка по цене от меньшего к большему (заменить `calculateUnlockedSortValue`)
|
||||||
|
2. Заблокированные: разделить на группы (с задачами/без задач) и сортировать каждую группу по времени
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Сортируем разблокированные по цене от меньшего к большему
|
||||||
|
// ЗАМЕНА: было calculateUnlockedSortValue, стало прямая сортировка по цене
|
||||||
|
sort.Slice(unlocked, func(i, j int) bool {
|
||||||
|
priceI := 0.0
|
||||||
|
priceJ := 0.0
|
||||||
|
if unlocked[i].Price != nil {
|
||||||
|
priceI = *unlocked[i].Price
|
||||||
|
}
|
||||||
|
if unlocked[j].Price != nil {
|
||||||
|
priceJ = *unlocked[j].Price
|
||||||
|
}
|
||||||
|
if priceI == priceJ {
|
||||||
|
return unlocked[i].ID < unlocked[j].ID
|
||||||
|
}
|
||||||
|
return priceI < priceJ // Сортировка по цене от меньшего к большему (заменяет calculateUnlockedSortValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Разделяем заблокированные на группы
|
||||||
|
lockedWithoutTasks := []WishlistItem{}
|
||||||
|
lockedWithTasks := []WishlistItem{}
|
||||||
|
|
||||||
|
for _, item := range locked {
|
||||||
|
hasUncompletedTasks := false
|
||||||
|
for _, cond := range item.UnlockConditions {
|
||||||
|
if cond.Type == "task_completion" && (cond.TaskCompleted == nil || !*cond.TaskCompleted) {
|
||||||
|
hasUncompletedTasks = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasUncompletedTasks {
|
||||||
|
lockedWithTasks = append(lockedWithTasks, item)
|
||||||
|
} else {
|
||||||
|
lockedWithoutTasks = append(lockedWithoutTasks, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем каждую группу по времени разблокировки
|
||||||
|
sort.Slice(lockedWithoutTasks, func(i, j int) bool {
|
||||||
|
valueI := a.calculateLockedSortValue(lockedWithoutTasks[i], userID)
|
||||||
|
valueJ := a.calculateLockedSortValue(lockedWithoutTasks[j], userID)
|
||||||
|
if valueI == valueJ {
|
||||||
|
return lockedWithoutTasks[i].ID < lockedWithoutTasks[j].ID
|
||||||
|
}
|
||||||
|
return valueI < valueJ
|
||||||
|
})
|
||||||
|
|
||||||
|
sort.Slice(lockedWithTasks, func(i, j int) bool {
|
||||||
|
valueI := a.calculateLockedSortValue(lockedWithTasks[i], userID)
|
||||||
|
valueJ := a.calculateLockedSortValue(lockedWithTasks[j], userID)
|
||||||
|
if valueI == valueJ {
|
||||||
|
return lockedWithTasks[i].ID < lockedWithTasks[j].ID
|
||||||
|
}
|
||||||
|
return valueI < valueJ
|
||||||
|
})
|
||||||
|
|
||||||
|
// Объединяем: сначала без задач, потом с задачами
|
||||||
|
locked = append(lockedWithoutTasks, lockedWithTasks...)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Обновить сортировку в getBoardItemsHandler
|
||||||
|
|
||||||
|
**Файл:** `play-life-backend/main.go` (строки 12222-12240)
|
||||||
|
|
||||||
|
**Текущее состояние:**
|
||||||
|
- Разблокированные: используют `calculateUnlockedSortValue` (сумма баллов) - **нужно заменить на цену**
|
||||||
|
- Заблокированные: сортируются по `calculateLockedSortValue` (недели) - **нужно разделить на группы**
|
||||||
|
|
||||||
|
**Изменить аналогично getWishlistHandler:**
|
||||||
|
1. Разблокированные: сортировка по цене от меньшего к большему (заменить `calculateUnlockedSortValue`)
|
||||||
|
2. Заблокированные: разделить на группы (с задачами/без задач) и сортировать каждую группу по времени
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Сортируем разблокированные по цене от меньшего к большему
|
||||||
|
// ЗАМЕНА: было calculateUnlockedSortValue, стало прямая сортировка по цене
|
||||||
|
sort.Slice(unlocked, func(i, j int) bool {
|
||||||
|
priceI := 0.0
|
||||||
|
priceJ := 0.0
|
||||||
|
if unlocked[i].Price != nil {
|
||||||
|
priceI = *unlocked[i].Price
|
||||||
|
}
|
||||||
|
if unlocked[j].Price != nil {
|
||||||
|
priceJ = *unlocked[j].Price
|
||||||
|
}
|
||||||
|
if priceI == priceJ {
|
||||||
|
return unlocked[i].ID < unlocked[j].ID
|
||||||
|
}
|
||||||
|
return priceI < priceJ
|
||||||
|
})
|
||||||
|
|
||||||
|
// РАЗДЕЛЕНИЕ НА ГРУППЫ: Заблокированные с задачами и без задач
|
||||||
|
// ЗАМЕНА: было просто sort.Slice(locked, ...), стало разделение на группы
|
||||||
|
lockedWithoutTasks := []WishlistItem{}
|
||||||
|
lockedWithTasks := []WishlistItem{}
|
||||||
|
|
||||||
|
for _, item := range locked {
|
||||||
|
hasUncompletedTasks := false
|
||||||
|
for _, cond := range item.UnlockConditions {
|
||||||
|
if cond.Type == "task_completion" && (cond.TaskCompleted == nil || !*cond.TaskCompleted) {
|
||||||
|
hasUncompletedTasks = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasUncompletedTasks {
|
||||||
|
lockedWithTasks = append(lockedWithTasks, item)
|
||||||
|
} else {
|
||||||
|
lockedWithoutTasks = append(lockedWithoutTasks, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем каждую группу по времени разблокировки
|
||||||
|
sort.Slice(lockedWithoutTasks, func(i, j int) bool {
|
||||||
|
valueI := a.calculateLockedSortValue(lockedWithoutTasks[i], userID)
|
||||||
|
valueJ := a.calculateLockedSortValue(lockedWithoutTasks[j], userID)
|
||||||
|
if valueI == valueJ {
|
||||||
|
return lockedWithoutTasks[i].ID < lockedWithoutTasks[j].ID
|
||||||
|
}
|
||||||
|
return valueI < valueJ
|
||||||
|
})
|
||||||
|
|
||||||
|
sort.Slice(lockedWithTasks, func(i, j int) bool {
|
||||||
|
valueI := a.calculateLockedSortValue(lockedWithTasks[i], userID)
|
||||||
|
valueJ := a.calculateLockedSortValue(lockedWithTasks[j], userID)
|
||||||
|
if valueI == valueJ {
|
||||||
|
return lockedWithTasks[i].ID < lockedWithTasks[j].ID
|
||||||
|
}
|
||||||
|
return valueI < valueJ
|
||||||
|
})
|
||||||
|
|
||||||
|
// Объединяем: сначала без задач, потом с задачами
|
||||||
|
locked = append(lockedWithoutTasks, lockedWithTasks...)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Итоговый порядок элементов
|
||||||
|
|
||||||
|
1. **Разблокированные** - отсортированы по цене от меньшего к большему
|
||||||
|
2. **Заблокированные без целей-задач** - отсортированы по максимальному времени разблокировки (среди всех проектов) от меньшего к большему
|
||||||
|
3. **Заблокированные с целями-задачами** - отсортированы по максимальному времени разблокировки (среди всех проектов) от меньшего к большему
|
||||||
|
|
||||||
|
## Обработка краевых случаев
|
||||||
|
|
||||||
|
- **Если медиана проекта = 0 или отсутствует**: `calculateProjectUnlockWeeks` возвращает 99999, что обрабатывается в `calculateLockedSortValue` (не учитывается в maxWeeks, если >= 99999)
|
||||||
|
- **Если нет условий**: возвращать 999999.0 (отсутствие условий = все условия выполнены)
|
||||||
|
- **Если все условия выполнены**: возвращать 999999.0
|
||||||
|
- **Если цена не указана (NULL)**: считать как 0.0
|
||||||
|
- **Если нет условий по проектам** (только задачи или нет условий): возвращать 999999.0
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
- `projects_median_mv` должна существовать (проверить наличие миграции или создать при необходимости)
|
||||||
|
- Функция `getProjectMedian` уже реализована (упрощенная версия без fallback)
|
||||||
|
- Функция `calculateProjectUnlockWeeks` уже реализована и используется в `calculateLockedSortValue`
|
||||||
|
|
||||||
|
## Финальный шаг: Перезапуск приложения
|
||||||
|
|
||||||
|
**После выполнения всех изменений:**
|
||||||
|
|
||||||
|
Выполнить команду для перезапуска фронтенда и бэкенда:
|
||||||
|
```bash
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Это пересоберет и перезапустит:
|
||||||
|
- Backend сервер (с пересборкой)
|
||||||
|
- Frontend приложение (с пересборкой)
|
||||||
|
- База данных
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
# План: Создать общие функции расчета и форматирования срока разблокировки
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Создать универсальные функции для расчета и форматирования срока разблокировки проекта, которые будут использоваться везде где необходимо считать остаточный срок.
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
### 1. Создать функцию расчета срока разблокировки (бэкенд)
|
||||||
|
|
||||||
|
**Файл:** `play-life-backend/main.go`
|
||||||
|
|
||||||
|
Создать функцию `calculateProjectUnlockWeeks`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// calculateProjectUnlockWeeks рассчитывает срок разблокировки проекта в неделях
|
||||||
|
// projectID - ID проекта
|
||||||
|
// requiredPoints - необходимое количество баллов
|
||||||
|
// startDate - дата начала подсчета (может быть nil - за всё время)
|
||||||
|
// userID - ID пользователя (владельца условия)
|
||||||
|
// Возвращает количество недель (float64):
|
||||||
|
// - > 0: условие не выполнено, возвращает количество недель
|
||||||
|
// - 0: условие уже выполнено (remaining <= 0)
|
||||||
|
// - 99999: медиана отсутствует или равна 0 (нельзя рассчитать)
|
||||||
|
func (a *App) calculateProjectUnlockWeeks(projectID int, requiredPoints float64, startDate sql.NullTime, userID int) float64 {
|
||||||
|
// 1. Получаем текущие баллы от startDate
|
||||||
|
currentPoints, err := a.calculateProjectPointsFromDate(projectID, startDate, userID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error calculating project points for project %d, user %d: %v", projectID, userID, err)
|
||||||
|
return 99999 // Ошибка расчета - возвращаем 99999
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Вычисляем остаток
|
||||||
|
remaining := requiredPoints - currentPoints
|
||||||
|
if remaining <= 0 {
|
||||||
|
// Условие уже выполнено
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Получаем медиану проекта
|
||||||
|
median, err := a.getProjectMedian(projectID)
|
||||||
|
if err != nil || median <= 0 {
|
||||||
|
// Если медиана отсутствует или равна 0, возвращаем 99999 (нельзя рассчитать)
|
||||||
|
// Это нормальная ситуация, не логируем
|
||||||
|
return 99999
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Рассчитываем недели
|
||||||
|
weeks := remaining / median
|
||||||
|
return weeks
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Примечание:** Функция возвращает:
|
||||||
|
|
||||||
|
- `0`: условие уже выполнено (remaining <= 0)
|
||||||
|
- `> 0 && < 99999`: количество недель до выполнения условия
|
||||||
|
- `99999`: медиана отсутствует или равна 0 (нельзя рассчитать) или ошибка расчета
|
||||||
|
|
||||||
|
````
|
||||||
|
|
||||||
|
### 2. Создать функцию форматирования срока (бэкенд)
|
||||||
|
|
||||||
|
**Файл:** `play-life-backend/main.go`
|
||||||
|
|
||||||
|
Создать функцию `formatWeeksText`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// formatWeeksText форматирует количество недель в текстовый формат
|
||||||
|
// weeks - количество недель (float64)
|
||||||
|
// Возвращает строку: "2 недели", "<1 недели", "5 недель", "∞ недель" и т.д.
|
||||||
|
func formatWeeksText(weeks float64) string {
|
||||||
|
// Если weeks == 0, условие уже выполнено - не показываем срок
|
||||||
|
if weeks == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если weeks >= 99999, это означает что медиана отсутствует или нельзя рассчитать
|
||||||
|
if weeks >= 99999 {
|
||||||
|
return "∞ недель"
|
||||||
|
}
|
||||||
|
|
||||||
|
if weeks < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if weeks < 1 {
|
||||||
|
return "<1 недели"
|
||||||
|
}
|
||||||
|
|
||||||
|
weeksRounded := math.Round(weeks)
|
||||||
|
weeksInt := int(weeksRounded)
|
||||||
|
|
||||||
|
// Правильное склонение для русского языка
|
||||||
|
var weekWord string
|
||||||
|
lastDigit := weeksInt % 10
|
||||||
|
lastTwoDigits := weeksInt % 100
|
||||||
|
|
||||||
|
if lastTwoDigits >= 11 && lastTwoDigits <= 14 {
|
||||||
|
weekWord = "недель"
|
||||||
|
} else if lastDigit == 1 {
|
||||||
|
weekWord = "неделя"
|
||||||
|
} else if lastDigit >= 2 && lastDigit <= 4 {
|
||||||
|
weekWord = "недели"
|
||||||
|
} else {
|
||||||
|
weekWord = "недель"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%d %s", weeksInt, weekWord)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Примечание:**
|
||||||
|
|
||||||
|
- Форматирование на бэкенде, так как сортировка происходит на бэкенде. Фронтенд получает уже отформатированную строку.
|
||||||
|
- При `weeks == 0` (условие выполнено) возвращается пустая строка (не показываем срок)
|
||||||
|
- При `weeks >= 99999` (медиана отсутствует, нельзя рассчитать или ошибка расчета) возвращается "∞ недель"
|
||||||
|
|
||||||
|
### 3. Использовать функции в calculateLockedSortValue
|
||||||
|
|
||||||
|
**Файл:** `play-life-backend/main.go` (строки 12314-12337)
|
||||||
|
|
||||||
|
Обновить функцию для использования `calculateProjectUnlockWeeks`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (a *App) calculateLockedSortValue(item WishlistItem, userID int) float64 {
|
||||||
|
// Если нет условий, возвращаем большое значение (отсутствие условий = все выполнены)
|
||||||
|
if len(item.UnlockConditions) == 0 {
|
||||||
|
return 999999.0
|
||||||
|
}
|
||||||
|
|
||||||
|
maxWeeks := 0.0
|
||||||
|
hasProjectConditions := false
|
||||||
|
|
||||||
|
for _, condition := range item.UnlockConditions {
|
||||||
|
if condition.Type == "project_points" {
|
||||||
|
hasProjectConditions = true
|
||||||
|
if condition.RequiredPoints != nil {
|
||||||
|
var startDate sql.NullTime
|
||||||
|
if condition.StartDate != nil {
|
||||||
|
date, err := time.Parse("2006-01-02", *condition.StartDate)
|
||||||
|
if err == nil {
|
||||||
|
startDate = sql.NullTime{Time: date, Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ВАЖНО: Используем владельца условия из condition.UserID
|
||||||
|
// Если condition.UserID есть - это владелец условия
|
||||||
|
// Если нет - получаем владельца желания из БД (для старых условий)
|
||||||
|
// НЕ используем текущего пользователя (userID), так как условие может принадлежать другому пользователю
|
||||||
|
conditionOwnerID := 0
|
||||||
|
if condition.UserID != nil {
|
||||||
|
conditionOwnerID = *condition.UserID
|
||||||
|
} else {
|
||||||
|
// Если нет владельца условия, получаем владельца желания из БД
|
||||||
|
var itemOwnerID int
|
||||||
|
err := a.DB.QueryRow(`SELECT user_id FROM wishlist_items WHERE id = $1`, item.ID).Scan(&itemOwnerID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error getting wishlist item owner for item %d: %v", item.ID, err)
|
||||||
|
continue // Пропускаем условие, если не можем получить владельца
|
||||||
|
}
|
||||||
|
conditionOwnerID = itemOwnerID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем projectID из условия
|
||||||
|
if condition.ProjectID != nil {
|
||||||
|
weeks := a.calculateProjectUnlockWeeks(
|
||||||
|
*condition.ProjectID,
|
||||||
|
*condition.RequiredPoints,
|
||||||
|
startDate,
|
||||||
|
conditionOwnerID, // Владелец условия, а не текущий пользователь
|
||||||
|
)
|
||||||
|
// weeks > 0 && < 99999 означает, что условие еще не выполнено и расчет успешен
|
||||||
|
// weeks == 0 означает условие выполнено
|
||||||
|
// weeks == 99999 означает медиана отсутствует (нельзя рассчитать) или ошибка расчета
|
||||||
|
if weeks > 0 && weeks < 99999 {
|
||||||
|
if weeks > maxWeeks {
|
||||||
|
maxWeeks = weeks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если были условия по проектам, но все выполнены (maxWeeks = 0)
|
||||||
|
if hasProjectConditions && maxWeeks == 0.0 {
|
||||||
|
return 999999.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если не было условий по проектам (только задачи или нет условий)
|
||||||
|
if !hasProjectConditions {
|
||||||
|
return 999999.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxWeeks
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Использовать функции в API endpoint для расчета недель
|
||||||
|
|
||||||
|
**Файл:** `play-life-backend/main.go`
|
||||||
|
|
||||||
|
Обновить endpoint `/api/wishlist/calculate-weeks` (из плана "добавить расчет недель в форму"):
|
||||||
|
|
||||||
|
**Важно:** Использовать владельца условия, а не текущего пользователя!
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (a *App) calculateWeeksHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// ... валидация и получение параметров ...
|
||||||
|
|
||||||
|
// Определяем владельца условия:
|
||||||
|
// 1. Если передан condition_user_id в запросе - используем его (для существующего условия)
|
||||||
|
// 2. Иначе используем текущего пользователя (для нового условия)
|
||||||
|
conditionOwnerID := userID // userID из контекста (текущий пользователь)
|
||||||
|
if req.ConditionUserID != nil && *req.ConditionUserID > 0 {
|
||||||
|
conditionOwnerID = *req.ConditionUserID
|
||||||
|
}
|
||||||
|
|
||||||
|
var startDate sql.NullTime
|
||||||
|
if req.StartDate != "" {
|
||||||
|
date, err := time.Parse("2006-01-02", req.StartDate)
|
||||||
|
if err == nil {
|
||||||
|
startDate = sql.NullTime{Time: date, Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем владельца условия, а не текущего пользователя
|
||||||
|
weeks := a.calculateProjectUnlockWeeks(req.ProjectID, req.RequiredPoints, startDate, conditionOwnerID)
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"weeks_text": formatWeeksText(weeks), // Отформатированная строка для отображения
|
||||||
|
}
|
||||||
|
|
||||||
|
// weeks используется только для сортировки на бэкенде, на клиент не отправляется
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Структура запроса:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
type CalculateWeeksRequest struct {
|
||||||
|
ProjectID int `json:"project_id"`
|
||||||
|
RequiredPoints float64 `json:"required_points"`
|
||||||
|
StartDate string `json:"start_date,omitempty"`
|
||||||
|
ConditionUserID *int `json:"condition_user_id,omitempty"` // Владелец условия (если условие существует)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Добавить weeks_text в UnlockConditionDisplay
|
||||||
|
|
||||||
|
**Файл:** `play-life-backend/main.go`
|
||||||
|
|
||||||
|
Добавить поле `WeeksText *string` в структуру `UnlockConditionDisplay`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type UnlockConditionDisplay struct {
|
||||||
|
// ... существующие поля ...
|
||||||
|
WeeksText *string `json:"weeks_text,omitempty"` // Отформатированный текст срока разблокировки
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
При загрузке условий типа `project_points` рассчитывать и форматировать срок:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if condition.Type == "project_points" && condition.RequiredPoints != nil && condition.ProjectID != nil {
|
||||||
|
var startDate sql.NullTime
|
||||||
|
if condition.StartDate != nil {
|
||||||
|
date, err := time.Parse("2006-01-02", *condition.StartDate)
|
||||||
|
if err == nil {
|
||||||
|
startDate = sql.NullTime{Time: date, Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ВАЖНО: Используем владельца условия из condition.UserID, а не текущего пользователя
|
||||||
|
// Если condition.UserID есть - это владелец условия
|
||||||
|
// Если нет - используем владельца желания (itemOwnerID), но НЕ текущего пользователя (userID)
|
||||||
|
conditionOwnerID := itemOwnerID // Владелец желания как fallback
|
||||||
|
if condition.UserID != nil {
|
||||||
|
conditionOwnerID = *condition.UserID // Владелец условия (приоритет)
|
||||||
|
}
|
||||||
|
|
||||||
|
weeks := a.calculateProjectUnlockWeeks(
|
||||||
|
*condition.ProjectID,
|
||||||
|
*condition.RequiredPoints,
|
||||||
|
startDate,
|
||||||
|
conditionOwnerID, // Владелец условия, а не текущий пользователь
|
||||||
|
)
|
||||||
|
|
||||||
|
// Форматируем всегда (при weeks == 0 вернет пустую строку, при weeks >= 99999 вернет "∞ недель")
|
||||||
|
weeksText := formatWeeksText(weeks)
|
||||||
|
condition.WeeksText = &weeksText
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:**
|
||||||
|
|
||||||
|
- `condition.UserID` - это владелец условия (из `wishlist_conditions.user_id`)
|
||||||
|
- `itemOwnerID` - это владелец желания (fallback для старых условий)
|
||||||
|
- `userID` (текущий пользователь) НЕ используется, так как условие может принадлежать другому пользователю
|
||||||
|
|
||||||
|
### 6. Использовать weeks_text на фронтенде
|
||||||
|
|
||||||
|
**Файл:** `play-life-web/src/components/WishlistDetail.jsx`
|
||||||
|
|
||||||
|
Использовать готовый `weeks_text` из условия (приходит уже отформатированным из API):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// В renderUnlockConditions:
|
||||||
|
{progress.remaining > 0 && condition.weeks_text && (
|
||||||
|
<span className="progress-remaining">
|
||||||
|
Осталось: {Math.round(progress.remaining)} ({condition.weeks_text})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Файл:** `play-life-web/src/components/WishlistForm.jsx`
|
||||||
|
|
||||||
|
Использовать `weeks_text` из ответа API для отображения недель в форме редактирования условия. Форматирование уже выполнено на бэкенде.
|
||||||
|
|
||||||
|
### 7. Обновить загрузку медианы в условиях (опционально)
|
||||||
|
|
||||||
|
**Файл:** `play-life-backend/main.go`
|
||||||
|
|
||||||
|
При загрузке условий типа `project_points` медиана не нужна отдельно, так как `calculateProjectUnlockWeeks` сама получит её и вернет уже отформатированный `weeks_text`.
|
||||||
|
|
||||||
|
## Места использования функций
|
||||||
|
|
||||||
|
1. **calculateProjectUnlockWeeks** (бэкенд):
|
||||||
|
|
||||||
|
- `calculateLockedSortValue` - для сортировки заблокированных желаний (использует числовое значение)
|
||||||
|
- `calculateWeeksHandler` - API endpoint для расчета недель (использует для расчета, но на клиент отправляется только отформатированная строка)
|
||||||
|
- При загрузке условий для расчета `weeks_text` (используется внутри, на клиент не отправляется)
|
||||||
|
- Любые другие места, где нужно рассчитать срок разблокировки
|
||||||
|
|
||||||
|
2. **formatWeeksText** (бэкенд):
|
||||||
|
|
||||||
|
- При загрузке условий в `UnlockConditionDisplay.WeeksText` (отправляется на клиент для отображения)
|
||||||
|
- В API endpoint `/api/wishlist/calculate-weeks` (отправляется на клиент для отображения в форме)
|
||||||
|
- Форматирование на бэкенде, так как сортировка происходит на бэкенде по числовому значению `weeks`
|
||||||
|
|
||||||
|
## Выявленные и исправленные проблемы
|
||||||
|
|
||||||
|
1. **Проблема с userID в calculateLockedSortValue**:
|
||||||
|
|
||||||
|
- **Проблема**: Использовался текущий пользователь (`userID`), но условие может принадлежать другому пользователю
|
||||||
|
- **Исправление**: Используется `conditionOwnerID` из `condition.UserID` (владелец условия). Если `condition.UserID` отсутствует, условие пропускается (некорректное состояние)
|
||||||
|
|
||||||
|
2. **Обработка отсутствия медианы**:
|
||||||
|
|
||||||
|
- **Решение**: При отсутствии медианы возвращается `99999` (нельзя рассчитать). В `formatWeeksText` это значение преобразуется в "∞ недель". Такие условия не учитываются при сортировке по времени разблокировки (проверка `weeks > 0 && weeks < 99999`)
|
||||||
|
|
||||||
|
3. **Форматирование и передача данных**:
|
||||||
|
|
||||||
|
- **Решение**: Форматирование на бэкенде, так как сортировка происходит на бэкенде по числовому значению `weeks`
|
||||||
|
- Числовое значение `weeks` используется только на бэкенде для сортировки, на клиент не отправляется
|
||||||
|
- На клиент отправляется только отформатированная строка `weeks_text` для отображения
|
||||||
|
- Фронтенд просто отображает готовую строку без дополнительного форматирования
|
||||||
|
- Это исключает дублирование логики и обеспечивает единообразие форматирования
|
||||||
|
|
||||||
|
4. **Использование правильного userID (владельца условия)**:
|
||||||
|
|
||||||
|
- **Проблема**: В функцию `calculateProjectUnlockWeeks` может передаваться текущий пользователь вместо владельца условия
|
||||||
|
- **Решение**:
|
||||||
|
- В `calculateLockedSortValue`: используется `condition.UserID` (владелец условия)
|
||||||
|
- В `calculateWeeksHandler`: используется `condition_user_id` из запроса (если передан) или текущий пользователь (для нового условия)
|
||||||
|
- При загрузке условий: используется `condition.UserID` или `itemOwnerID` (владелец желания), но НЕ текущий пользователь
|
||||||
|
- **Важно**: Условие может принадлежать другому пользователю (на общих досках), поэтому нужно использовать именно владельца условия
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
- Функция `getProjectMedian` должна быть создана (из плана сортировки)
|
||||||
|
- Функция `calculateProjectPointsFromDate` уже существует
|
||||||
|
|
||||||
|
## Финальный шаг: Перезапуск приложения
|
||||||
|
|
||||||
|
**После выполнения всех изменений:**
|
||||||
|
|
||||||
|
Выполнить команду для перезапуска фронтенда и бэкенда:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Это пересоберет и перезапустит:
|
||||||
|
|
||||||
|
- Backend сервер (с пересборкой)
|
||||||
|
- Frontend приложение (с пересборкой)
|
||||||
|
- База данных
|
||||||
8
.cursor/rules/migrations.mdc
Normal file
8
.cursor/rules/migrations.mdc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
description: "Запрет доработок старых миграций"
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
**ВАЖНО:** Если ты меняешь структуру базы данных - напиши НОВУЮ миграцию.
|
||||||
|
НИ В КОЕМ СЛУЧАЕ не меняй старые миграции, можно добавлять только новые.
|
||||||
|
Старой миграцией считается та что была уже ранее закомичена
|
||||||
16
.cursor/rules/restart_on_changes.mdc
Normal file
16
.cursor/rules/restart_on_changes.mdc
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
description: "Перезапуск приложения после изменений в бэкенде или фронтенде"
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## Правило перезапуска приложения
|
||||||
|
|
||||||
|
**ВАЖНО:** После применения всех изменений в бэкенде (`play-life-backend/`) или фронтенде (`play-life-web/`), а также после изменений в `docker-compose.yml`, **ОБЯЗАТЕЛЬНО** выполни команду `./run.sh` для перезапуска всех сервисов приложения.
|
||||||
|
|
||||||
|
Это правило применяется при работе с:
|
||||||
|
- Go кодом в `play-life-backend/`
|
||||||
|
- Миграциями базы данных в `play-life-backend/migrations/`
|
||||||
|
- React компонентами и стилями в `play-life-web/src/`
|
||||||
|
- Docker конфигурациями (`docker-compose.yml`, `Dockerfile`)
|
||||||
|
|
||||||
|
**Команда для перезапуска:** `./run.sh` или `bash run.sh` в корне проекта.
|
||||||
71
.cursor/rules/version_bump_and_push.mdc
Normal file
71
.cursor/rules/version_bump_and_push.mdc
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
description: "Правило для поднятия версии и пуша в git"
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## Правило поднятия версии и пуша
|
||||||
|
|
||||||
|
Когда пользователь просит **поднять версию и запушить**, выполни следующие шаги:
|
||||||
|
|
||||||
|
### 1. Определи тип версии
|
||||||
|
|
||||||
|
Определи по сообщению пользователя, какую часть версии нужно поднять:
|
||||||
|
- **major** (мажор) - первая цифра (например: 1.1.1 → 2.0.0), минор и патч должны обнулиться
|
||||||
|
- **minor** (минор) - вторая цифра (например: 1.0.1 → 1.1.0), патч должна обнулиться
|
||||||
|
- **patch** (патч) - третья цифра (например: 1.0.0 → 1.0.1)
|
||||||
|
Любая часть версии может быть больше 9, то есть может быть версия 10, 11, 12 и тд.
|
||||||
|
|
||||||
|
**Если тип версии непонятен из контекста — обязательно спроси у пользователя!**
|
||||||
|
|
||||||
|
### 2. Обнови версию в файлах
|
||||||
|
|
||||||
|
Обнови версию в двух файлах:
|
||||||
|
- `VERSION` (в корне проекта)
|
||||||
|
- `play-life-web/package.json` (поле `"version"`)
|
||||||
|
|
||||||
|
### 3. Проанализируй git diff
|
||||||
|
|
||||||
|
Выполни `git diff --staged` и `git diff` для анализа изменений. На основе изменений составь **короткий commit message** (максимум 50 символов) на русском языке, описывающий суть изменений. В начале commit message должна быть указана версия на которую осуществился переход в формате "1.2.3: Коммит мессадж"
|
||||||
|
|
||||||
|
### 4. Закоммить изменения
|
||||||
|
|
||||||
|
Выполни:
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "<commit message>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Запушь в репозиторий
|
||||||
|
|
||||||
|
Выполни:
|
||||||
|
```bash
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Правило пуша без поднятия версии
|
||||||
|
|
||||||
|
### 1. Проанализируй git diff
|
||||||
|
|
||||||
|
Выполни `git diff --staged` и `git diff` для анализа изменений. На основе изменений составь **короткий commit message** (максимум 50 символов) на русском языке, описывающий суть изменений
|
||||||
|
|
||||||
|
### 2. Закоммить изменения
|
||||||
|
|
||||||
|
Выполни:
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "<commit message>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Запушь в репозиторий
|
||||||
|
|
||||||
|
Выполни:
|
||||||
|
```bash
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
---
|
||||||
|
|
||||||
|
**Пример использования:**
|
||||||
|
- "Подними патч и запушь" → поднять patch версию
|
||||||
|
- "Bump minor and push" → поднять minor версию
|
||||||
|
- "Подними версию и запушь" → спросить какой тип версии поднять
|
||||||
|
- "Запуш именения" → запушить без изменения версии
|
||||||
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Игнорируем node_modules при копировании
|
||||||
|
play-life-web/node_modules
|
||||||
|
play-life-web/dist
|
||||||
|
play-life-web/.git
|
||||||
|
play-life-backend/.git
|
||||||
|
*.md
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
85
.env.test
Normal file
85
.env.test
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# ============================================
|
||||||
|
# Единый файл конфигурации для всех проектов
|
||||||
|
# Backend и Play-Life-Web
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Database Configuration
|
||||||
|
# ============================================
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=playeng
|
||||||
|
DB_PASSWORD=playeng
|
||||||
|
DB_NAME=playeng_migration_test_1769347550
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Backend Server Configuration
|
||||||
|
# ============================================
|
||||||
|
# Порт для backend сервера (по умолчанию: 8080)
|
||||||
|
# В production всегда используется порт 8080 внутри контейнера
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Play Life Web Configuration
|
||||||
|
# ============================================
|
||||||
|
# Порт для frontend приложения play-life-web
|
||||||
|
WEB_PORT=3001
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Telegram Bot Configuration
|
||||||
|
# ============================================
|
||||||
|
# Токен единого бота для всех пользователей
|
||||||
|
# Получить у @BotFather: https://t.me/botfather
|
||||||
|
TELEGRAM_BOT_TOKEN=your-bot-token-here
|
||||||
|
|
||||||
|
# Base URL для автоматической настройки webhook
|
||||||
|
# Примеры:
|
||||||
|
# - Для production с HTTPS: https://your-domain.com
|
||||||
|
# - Для локальной разработки с ngrok: https://abc123.ngrok.io
|
||||||
|
# - Для прямого доступа на нестандартном порту: http://your-server:8080
|
||||||
|
# Webhook будет настроен автоматически при старте сервера на: <WEBHOOK_BASE_URL>/webhook/telegram
|
||||||
|
# Если не указан, webhook нужно настраивать вручную
|
||||||
|
WEBHOOK_BASE_URL=https://your-domain.com
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Todoist Integration Configuration
|
||||||
|
# ============================================
|
||||||
|
# Единое Todoist приложение для всех пользователей Play Life
|
||||||
|
# Настроить в: https://developer.todoist.com/appconsole.html
|
||||||
|
#
|
||||||
|
# В настройках Todoist приложения указать:
|
||||||
|
# - OAuth Redirect URL: <WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback
|
||||||
|
# - Webhooks callback URL: <WEBHOOK_BASE_URL>/webhook/todoist
|
||||||
|
# - Watched events: item:completed
|
||||||
|
|
||||||
|
# Client ID единого Todoist приложения
|
||||||
|
TODOIST_CLIENT_ID=
|
||||||
|
|
||||||
|
# Client Secret единого Todoist приложения
|
||||||
|
TODOIST_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Секрет для проверки подлинности webhook от Todoist (опционально)
|
||||||
|
# Получить в Developer Console: "Client secret for webhooks"
|
||||||
|
TODOIST_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Authentication Configuration
|
||||||
|
# ============================================
|
||||||
|
# Секретный ключ для подписи JWT токенов
|
||||||
|
# ВАЖНО: Обязательно задайте свой уникальный секретный ключ для production!
|
||||||
|
# Если не задан, будет использован случайно сгенерированный (не рекомендуется для production)
|
||||||
|
# Можно сгенерировать с помощью: openssl rand -base64 32
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Scheduler Configuration
|
||||||
|
# ============================================
|
||||||
|
# Часовой пояс для планировщика задач (например: Europe/Moscow, America/New_York, UTC)
|
||||||
|
# Используется для:
|
||||||
|
# - Автоматической фиксации целей на неделю каждый понедельник в 6:00
|
||||||
|
# - Отправки ежедневного отчёта в 23:59
|
||||||
|
# ВАЖНО: Укажите правильный часовой пояс, иначе задачи будут срабатывать в UTC!
|
||||||
|
# Список доступных часовых поясов: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||||
|
TIMEZONE=Europe/Moscow
|
||||||
|
|
||||||
|
DB_NAME=playeng_migration_test_1769347550
|
||||||
215
.gitea/workflows/build-and-push.yml
Normal file
215
.gitea/workflows/build-and-push.yml
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest # Убедитесь, что у вашего раннера есть этот тег
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get versions and check change
|
||||||
|
id: version_check
|
||||||
|
run: |
|
||||||
|
# Извлекаем текущую версию
|
||||||
|
CUR=$(cat VERSION | tr -d '[:space:]')
|
||||||
|
echo "current=$CUR" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Извлекаем сообщение последнего коммита
|
||||||
|
COMMIT_MSG=$(git log -1 --pretty=%B | head -1)
|
||||||
|
echo "commit_message=$COMMIT_MSG" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Безопасно извлекаем старую версию
|
||||||
|
PREV=$(git show HEAD~1:VERSION 2>/dev/null | tr -d '[:space:]' || echo "none")
|
||||||
|
|
||||||
|
if [ "$CUR" != "$PREV" ]; then
|
||||||
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Patch DNS for Local Network
|
||||||
|
run: |
|
||||||
|
# Записываем IP Synology прямо в контейнер сборки
|
||||||
|
echo "192.168.50.55 dungeonsiege.synology.me" | sudo tee -a /etc/hosts
|
||||||
|
|
||||||
|
- name: Build Docker Image
|
||||||
|
id: build
|
||||||
|
run: |
|
||||||
|
REGISTRY="dungeonsiege.synology.me/poignatov/play-life"
|
||||||
|
VER="${{ steps.version_check.outputs.current }}"
|
||||||
|
|
||||||
|
echo "Building Docker image..."
|
||||||
|
echo "Registry: $REGISTRY"
|
||||||
|
echo "Tag: latest"
|
||||||
|
|
||||||
|
# Собираем образ
|
||||||
|
docker build -t $REGISTRY:latest .
|
||||||
|
|
||||||
|
echo "✅ Successfully built image: $REGISTRY:latest"
|
||||||
|
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
if: steps.version_check.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.GIT_TOKEN }}" | docker login dungeonsiege.synology.me -u ${{ secrets.GIT_USERNAME }} --password-stdin
|
||||||
|
|
||||||
|
- name: Push Docker Image
|
||||||
|
id: push
|
||||||
|
if: steps.version_check.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
REGISTRY="dungeonsiege.synology.me/poignatov/play-life"
|
||||||
|
VER="${{ steps.version_check.outputs.current }}"
|
||||||
|
|
||||||
|
# Тегируем образ версией
|
||||||
|
docker tag $REGISTRY:latest $REGISTRY:$VER
|
||||||
|
|
||||||
|
# Пушим оба тега
|
||||||
|
echo "Pushing image to registry..."
|
||||||
|
docker push $REGISTRY:latest
|
||||||
|
docker push $REGISTRY:$VER
|
||||||
|
|
||||||
|
echo "✅ Successfully pushed to registry:"
|
||||||
|
echo " - $REGISTRY:latest"
|
||||||
|
echo " - $REGISTRY:$VER"
|
||||||
|
|
||||||
|
- name: Send Telegram notification (build success)
|
||||||
|
if: success() && steps.version_check.outputs.changed == 'false'
|
||||||
|
uses: appleboy/telegram-action@master
|
||||||
|
with:
|
||||||
|
to: ${{ secrets.TELEGRAM_TO }}
|
||||||
|
token: ${{ secrets.TELEGRAM_TOKEN }}
|
||||||
|
format: markdown
|
||||||
|
message: |
|
||||||
|
*play-life*
|
||||||
|
`${{ steps.version_check.outputs.commit_message }}`
|
||||||
|
|
||||||
|
Build: ✅
|
||||||
|
Registration: ⏭️
|
||||||
|
Deploy: ⏭️
|
||||||
|
|
||||||
|
- name: Deploy to Production Server
|
||||||
|
id: deploy
|
||||||
|
if: steps.version_check.outputs.changed == 'true'
|
||||||
|
uses: appleboy/ssh-action@master
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
username: ${{ secrets.DEPLOY_USER }}
|
||||||
|
password: ${{ secrets.DEPLOY_PASSWORD }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Расширяем PATH для Synology (при SSH сессии PATH минимальный)
|
||||||
|
export PATH="/usr/local/bin:/usr/syno/bin:$PATH"
|
||||||
|
|
||||||
|
REGISTRY="dungeonsiege.synology.me/poignatov/play-life"
|
||||||
|
DEPLOY_PATH="/volume1/docker/play-life"
|
||||||
|
|
||||||
|
echo "🚀 Начинаю деплой на production сервер..."
|
||||||
|
echo "PATH: $PATH"
|
||||||
|
|
||||||
|
# Проверяем наличие docker
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "❌ Docker не найден в PATH!"
|
||||||
|
echo "Пробуем найти docker..."
|
||||||
|
which docker || find /usr -name "docker" -type f 2>/dev/null | head -5
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DOCKER_CMD="docker"
|
||||||
|
|
||||||
|
# Определяем docker-compose (может быть docker compose или docker-compose)
|
||||||
|
if command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
DOCKER_COMPOSE_CMD="docker-compose"
|
||||||
|
elif docker compose version >/dev/null 2>&1; then
|
||||||
|
DOCKER_COMPOSE_CMD="docker compose"
|
||||||
|
else
|
||||||
|
echo "❌ Docker Compose не найден!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Используем: $DOCKER_CMD и $DOCKER_COMPOSE_CMD"
|
||||||
|
|
||||||
|
# Переходим в директорию проекта
|
||||||
|
cd $DEPLOY_PATH
|
||||||
|
|
||||||
|
# Логинимся в registry
|
||||||
|
echo "${{ secrets.GIT_TOKEN }}" | $DOCKER_CMD login dungeonsiege.synology.me -u ${{ secrets.GIT_USERNAME }} --password-stdin
|
||||||
|
|
||||||
|
# Обновляем образ
|
||||||
|
echo "📥 Обновляю образ из registry..."
|
||||||
|
$DOCKER_CMD pull $REGISTRY:latest
|
||||||
|
|
||||||
|
# Перезапускаем контейнеры
|
||||||
|
echo "🔄 Перезапускаю контейнеры..."
|
||||||
|
$DOCKER_COMPOSE_CMD -f docker-compose.prod.yml up -d --force-recreate
|
||||||
|
|
||||||
|
# Проверяем статус
|
||||||
|
echo "✅ Деплой завершен успешно"
|
||||||
|
$DOCKER_COMPOSE_CMD -f docker-compose.prod.yml ps
|
||||||
|
|
||||||
|
- name: Send Telegram notification (publish success)
|
||||||
|
if: steps.build.outcome == 'success' && steps.version_check.outputs.changed == 'true' && steps.push.outcome == 'success' && steps.deploy.outcome == 'success'
|
||||||
|
uses: appleboy/telegram-action@master
|
||||||
|
with:
|
||||||
|
to: ${{ secrets.TELEGRAM_TO }}
|
||||||
|
token: ${{ secrets.TELEGRAM_TOKEN }}
|
||||||
|
format: markdown
|
||||||
|
message: |
|
||||||
|
*play-life*
|
||||||
|
`${{ steps.version_check.outputs.commit_message }}`
|
||||||
|
|
||||||
|
Build: ✅
|
||||||
|
Registration: ✅
|
||||||
|
Deploy: ✅
|
||||||
|
|
||||||
|
- name: Send Telegram notification (push failure)
|
||||||
|
if: steps.build.outcome == 'success' && steps.version_check.outputs.changed == 'true' && steps.push.outcome == 'failure'
|
||||||
|
uses: appleboy/telegram-action@master
|
||||||
|
with:
|
||||||
|
to: ${{ secrets.TELEGRAM_TO }}
|
||||||
|
token: ${{ secrets.TELEGRAM_TOKEN }}
|
||||||
|
format: markdown
|
||||||
|
message: |
|
||||||
|
*play-life*
|
||||||
|
`${{ steps.version_check.outputs.commit_message }}`
|
||||||
|
|
||||||
|
Build: ✅
|
||||||
|
Registration: ❌
|
||||||
|
Deploy: ⏭️
|
||||||
|
|
||||||
|
- name: Send Telegram notification (deploy failure)
|
||||||
|
if: steps.build.outcome == 'success' && steps.push.outcome == 'success' && steps.version_check.outputs.changed == 'true' && steps.deploy.outcome == 'failure'
|
||||||
|
uses: appleboy/telegram-action@master
|
||||||
|
with:
|
||||||
|
to: ${{ secrets.TELEGRAM_TO }}
|
||||||
|
token: ${{ secrets.TELEGRAM_TOKEN }}
|
||||||
|
format: markdown
|
||||||
|
message: |
|
||||||
|
*play-life*
|
||||||
|
`${{ steps.version_check.outputs.commit_message }}`
|
||||||
|
|
||||||
|
Build: ✅
|
||||||
|
Registration: ✅
|
||||||
|
Deploy: ❌
|
||||||
|
|
||||||
|
- name: Send Telegram notification (build failure)
|
||||||
|
if: steps.build.outcome == 'failure'
|
||||||
|
uses: appleboy/telegram-action@master
|
||||||
|
with:
|
||||||
|
to: ${{ secrets.TELEGRAM_TO }}
|
||||||
|
token: ${{ secrets.TELEGRAM_TOKEN }}
|
||||||
|
format: markdown
|
||||||
|
message: |
|
||||||
|
*play-life*
|
||||||
|
`${{ steps.version_check.outputs.commit_message }}`
|
||||||
|
|
||||||
|
Build: ❌
|
||||||
|
Registration: ⏭️
|
||||||
|
Deploy: ⏭️
|
||||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.prod
|
||||||
|
*.log
|
||||||
|
main
|
||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
*.tar
|
||||||
|
|
||||||
|
# Database dumps
|
||||||
|
database-dumps/*.sql
|
||||||
|
database-dumps/*.sql.gz
|
||||||
|
!database-dumps/.gitkeep
|
||||||
|
|
||||||
|
# Uploaded files
|
||||||
|
uploads/
|
||||||
4
.vscode/launch.json
vendored
Normal file
4
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": []
|
||||||
|
}
|
||||||
82
.vscode/tasks.json
vendored
Normal file
82
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "init",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "./init.sh",
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": false
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": true,
|
||||||
|
"clear": false
|
||||||
|
},
|
||||||
|
"problemMatcher": [],
|
||||||
|
"detail": "Инициализация Play Life: остановка контейнеров, поднятие сервисов, создание дампа с продакшена и восстановление в локальную базу"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "run",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "./run.sh",
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": false
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": true,
|
||||||
|
"clear": false
|
||||||
|
},
|
||||||
|
"problemMatcher": [],
|
||||||
|
"detail": "Перезапуск Play Life: перезапуск всех контейнеров"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "backupFromProd",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "./dump-db.sh",
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": false
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": true,
|
||||||
|
"clear": false
|
||||||
|
},
|
||||||
|
"problemMatcher": [],
|
||||||
|
"detail": "Создание дампа базы данных с продакшена"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "restoreToLocal",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "./restore-db.sh",
|
||||||
|
"group": {
|
||||||
|
"kind": "build",
|
||||||
|
"isDefault": false
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"echo": true,
|
||||||
|
"reveal": "always",
|
||||||
|
"focus": false,
|
||||||
|
"panel": "shared",
|
||||||
|
"showReuseMessage": true,
|
||||||
|
"clear": false
|
||||||
|
},
|
||||||
|
"problemMatcher": [],
|
||||||
|
"detail": "Восстановление базы данных из самого свежего дампа в локальную базу (автоматически выбирает последний дамп)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
80
BUILD_INSTRUCTIONS.md
Normal file
80
BUILD_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Инструкция по сборке единого Docker образа
|
||||||
|
|
||||||
|
Этот проект содержит единый Dockerfile для сборки frontend и backend в один образ.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
- `Dockerfile` - единый Dockerfile для сборки frontend и backend
|
||||||
|
- `nginx-unified.conf` - конфигурация nginx для единого образа
|
||||||
|
- `supervisord.conf` - конфигурация supervisor для запуска nginx и backend
|
||||||
|
- `build-and-save.sh` - скрипт для сборки и сохранения в tar (Linux/Mac)
|
||||||
|
- `build-and-save.ps1` - скрипт для сборки и сохранения в tar (Windows PowerShell)
|
||||||
|
|
||||||
|
## Сборка образа
|
||||||
|
|
||||||
|
### Linux/Mac:
|
||||||
|
```bash
|
||||||
|
./build-and-save.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows PowerShell:
|
||||||
|
```powershell
|
||||||
|
.\build-and-save.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вручную:
|
||||||
|
```bash
|
||||||
|
# Сборка образа
|
||||||
|
docker build -t play-life-unified:latest .
|
||||||
|
|
||||||
|
# Сохранение в tar
|
||||||
|
docker save play-life-unified:latest -o play-life-unified.tar
|
||||||
|
```
|
||||||
|
|
||||||
|
## Загрузка образа на другой машине
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker load -i play-life-unified.tar
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск контейнера
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-p 80:80 \
|
||||||
|
--env-file .env \
|
||||||
|
--name play-life \
|
||||||
|
play-life-unified:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
Создайте файл `.env` на основе `env.example` с необходимыми переменными:
|
||||||
|
|
||||||
|
- `DB_HOST` - хост базы данных (по умолчанию: localhost)
|
||||||
|
- `DB_PORT` - порт базы данных (по умолчанию: 5432)
|
||||||
|
- `DB_USER` - пользователь БД
|
||||||
|
- `DB_PASSWORD` - пароль БД
|
||||||
|
- `DB_NAME` - имя БД
|
||||||
|
- `WEBHOOK_BASE_URL` - базовый URL для webhook (опционально)
|
||||||
|
- Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram"
|
||||||
|
- `TODOIST_WEBHOOK_SECRET` - секрет для Todoist webhook (опционально)
|
||||||
|
|
||||||
|
**Важно:** Backend внутри контейнера всегда работает на порту 8080. Nginx проксирует запросы с порта 80 на backend.
|
||||||
|
|
||||||
|
## Проверка работы
|
||||||
|
|
||||||
|
После запуска контейнера:
|
||||||
|
|
||||||
|
- Frontend доступен по адресу: `http://localhost`
|
||||||
|
- API доступен через nginx: `http://localhost/api/...`
|
||||||
|
- Admin панель: `http://localhost/admin.html`
|
||||||
|
|
||||||
|
## Логи
|
||||||
|
|
||||||
|
Логи доступны через supervisor:
|
||||||
|
```bash
|
||||||
|
docker exec play-life cat /var/log/supervisor/backend.out.log
|
||||||
|
docker exec play-life cat /var/log/supervisor/nginx.out.log
|
||||||
|
```
|
||||||
|
|
||||||
71
Dockerfile
Normal file
71
Dockerfile
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Multi-stage build для единого образа frontend + backend
|
||||||
|
|
||||||
|
# Stage 1: Build Frontend
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY play-life-web/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
# Копируем исходники (node_modules исключены через .dockerignore)
|
||||||
|
COPY play-life-web/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Build Backend
|
||||||
|
FROM golang:1.24-alpine AS backend-builder
|
||||||
|
WORKDIR /app/backend
|
||||||
|
# Устанавливаем GOPROXY для более надежной загрузки модулей
|
||||||
|
ENV GOPROXY=https://proxy.golang.org,direct
|
||||||
|
ENV GOSUMDB=sum.golang.org
|
||||||
|
COPY play-life-backend/go.mod play-life-backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY play-life-backend/ .
|
||||||
|
RUN go mod tidy
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
|
||||||
|
|
||||||
|
# Stage 3: Final image
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Устанавливаем необходимые пакеты
|
||||||
|
# tzdata — данные о часовых поясах для корректной работы планировщика
|
||||||
|
RUN apk --no-cache add \
|
||||||
|
ca-certificates \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
curl \
|
||||||
|
tzdata
|
||||||
|
|
||||||
|
# Создаем директории
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Создаем директорию для загруженных файлов
|
||||||
|
RUN mkdir -p /app/uploads/wishlist && \
|
||||||
|
chmod 755 /app/uploads
|
||||||
|
|
||||||
|
# Копируем собранный frontend
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Копируем собранный backend
|
||||||
|
COPY --from=backend-builder /app/backend/main /app/backend/main
|
||||||
|
COPY play-life-backend/admin.html /app/backend/admin.html
|
||||||
|
# Копируем миграции для применения при запуске
|
||||||
|
COPY play-life-backend/migrations /migrations
|
||||||
|
# Копируем файл версии
|
||||||
|
COPY VERSION /app/VERSION
|
||||||
|
|
||||||
|
# Копируем конфигурацию nginx
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY nginx-unified.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Копируем конфигурацию supervisor для запуска backend
|
||||||
|
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
|
# Создаем директории для логов
|
||||||
|
RUN mkdir -p /var/log/supervisor && \
|
||||||
|
mkdir -p /var/log/nginx && \
|
||||||
|
mkdir -p /var/run
|
||||||
|
|
||||||
|
# Открываем порт 80
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Запускаем supervisor, который запустит nginx и backend
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|
||||||
298
ENV_SETUP.md
Normal file
298
ENV_SETUP.md
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
# Настройка единого .env файла
|
||||||
|
|
||||||
|
Все приложения проекта используют единый файл `.env` в корне проекта.
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
1. Скопируйте файл `.env.example` в `.env`:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Отредактируйте `.env` и укажите свои значения:
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
# или
|
||||||
|
vim .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **ВАЖНО**: Файл `.env` уже добавлен в `.gitignore` и не будет попадать в git.
|
||||||
|
|
||||||
|
## Структура переменных окружения
|
||||||
|
|
||||||
|
### Database Configuration
|
||||||
|
- `DB_HOST` - хост базы данных (по умолчанию: localhost)
|
||||||
|
- `DB_PORT` - порт базы данных (по умолчанию: 5432)
|
||||||
|
- `DB_USER` - пользователь БД (по умолчанию: playeng)
|
||||||
|
- `DB_PASSWORD` - пароль БД (по умолчанию: playeng)
|
||||||
|
- `DB_NAME` - имя БД (по умолчанию: playeng)
|
||||||
|
|
||||||
|
### Backend Server Configuration
|
||||||
|
- `PORT` - порт бэкенд сервера (по умолчанию: 8080)
|
||||||
|
- В production всегда используется порт 8080 внутри контейнера
|
||||||
|
- Nginx автоматически проксирует запросы к `http://backend:8080`
|
||||||
|
|
||||||
|
### Frontend Configuration (play-life-web)
|
||||||
|
- `VITE_PORT` - порт для dev-сервера Vite (по умолчанию: 3000)
|
||||||
|
- `WEB_PORT` - порт для production контейнера (по умолчанию: 3001)
|
||||||
|
|
||||||
|
**Примечание:** API запросы автоматически проксируются к бэкенду. В development режиме Vite проксирует запросы к `http://localhost:8080`. В production nginx проксирует запросы к бэкенд контейнеру. Не требуется настройка `VITE_API_BASE_URL`.
|
||||||
|
|
||||||
|
### Telegram Bot Configuration
|
||||||
|
- `WEBHOOK_BASE_URL` - базовый URL для автоматической настройки webhook. Webhook будет настроен автоматически при сохранении bot token через UI на `<WEBHOOK_BASE_URL>/webhook/telegram`.
|
||||||
|
- Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram"
|
||||||
|
|
||||||
|
**Примеры значений:**
|
||||||
|
- Production с HTTPS: `https://your-domain.com` (порт не нужен для стандартных 80/443)
|
||||||
|
- Локальная разработка с ngrok: `https://abc123.ngrok.io` (порт не нужен)
|
||||||
|
- Прямой доступ на нестандартном порту: `http://your-server:8080` (порт обязателен)
|
||||||
|
|
||||||
|
### Todoist Webhook Configuration (опционально)
|
||||||
|
- `TODOIST_WEBHOOK_SECRET` - секрет для проверки подлинности webhook от Todoist (если задан, все запросы должны содержать заголовок `X-Todoist-Webhook-Secret` с этим значением)
|
||||||
|
|
||||||
|
## Настройка интеграции с Todoist
|
||||||
|
|
||||||
|
Интеграция с Todoist позволяет автоматически обрабатывать закрытые задачи и добавлять их в базу данных play-life.
|
||||||
|
|
||||||
|
### Как это работает
|
||||||
|
|
||||||
|
1. При закрытии задачи в Todoist отправляется webhook на ваш сервер
|
||||||
|
2. Сервер извлекает `title` (content) и `description` из закрытой задачи
|
||||||
|
3. Склеивает их в один текст: `title + "\n" + description`
|
||||||
|
4. Обрабатывает текст через существующую логику `processMessage`, которая:
|
||||||
|
- Парсит ноды в формате `**[Project][+/-][Score]**`
|
||||||
|
- Сохраняет данные в базу данных
|
||||||
|
- Отправляет уведомление в Telegram (если настроено)
|
||||||
|
|
||||||
|
### Настройка webhook в Todoist
|
||||||
|
|
||||||
|
1. Откройте настройки Todoist: https://todoist.com/app/settings/integrations
|
||||||
|
2. Перейдите в раздел "Webhooks" или "Integrations"
|
||||||
|
3. Создайте новый webhook:
|
||||||
|
- **URL**: `http://your-server:8080/webhook/todoist`
|
||||||
|
- Для локальной разработки: `http://localhost:8080/webhook/todoist`
|
||||||
|
- Для production: укажите публичный URL вашего сервера
|
||||||
|
- **Event**: выберите `item:completed` (закрытие задачи)
|
||||||
|
4. Сохраните webhook
|
||||||
|
|
||||||
|
### Безопасность (опционально)
|
||||||
|
|
||||||
|
Для защиты webhook от несанкционированного доступа:
|
||||||
|
|
||||||
|
1. Установите секрет в `.env`:
|
||||||
|
```bash
|
||||||
|
TODOIST_WEBHOOK_SECRET=your_secret_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Настройте Todoist для отправки секрета в заголовке:
|
||||||
|
- В настройках webhook добавьте заголовок: `X-Todoist-Webhook-Secret: your_secret_key_here`
|
||||||
|
- Или используйте встроенные механизмы безопасности Todoist, если они доступны
|
||||||
|
|
||||||
|
**Примечание**: Если `TODOIST_WEBHOOK_SECRET` не задан, проверка секрета не выполняется.
|
||||||
|
|
||||||
|
### Формат задач в Todoist
|
||||||
|
|
||||||
|
Для корректной обработки задачи должны содержать ноды в формате:
|
||||||
|
```
|
||||||
|
**[ProjectName][+/-][Score]**
|
||||||
|
```
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
- `**[Work]+5.5**` - добавить 5.5 баллов к проекту "Work"
|
||||||
|
- `**[Health]-2.0**` - вычесть 2.0 баллов из проекта "Health"
|
||||||
|
|
||||||
|
Ноды можно размещать как в `title` (content), так и в `description` задачи. Они будут обработаны при закрытии задачи.
|
||||||
|
|
||||||
|
### Тестирование
|
||||||
|
|
||||||
|
Для тестирования интеграции:
|
||||||
|
|
||||||
|
1. Создайте задачу в Todoist с нодами, например:
|
||||||
|
- Title: `Test task`
|
||||||
|
- Description: `**[TestProject]+10.0**`
|
||||||
|
|
||||||
|
2. Закройте задачу в Todoist
|
||||||
|
|
||||||
|
3. Проверьте логи сервера - должно появиться сообщение:
|
||||||
|
```
|
||||||
|
Processing Todoist task: title='Test task', description='**[TestProject]+10.0**'
|
||||||
|
Successfully processed Todoist task, found 1 nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Проверьте базу данных или веб-интерфейс - данные должны быть добавлены
|
||||||
|
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Локальная разработка
|
||||||
|
|
||||||
|
Все приложения автоматически читают переменные из корневого `.env` файла:
|
||||||
|
|
||||||
|
- **play-life-backend**: читает из `../.env` и `.env` (локальный имеет приоритет)
|
||||||
|
- **play-life-web**: читает из `../.env` и `.env` (локальный имеет приоритет)
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
Для запуска всех приложений в одном образе используйте корневой `docker-compose.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Все сервисы автоматически загружают переменные из корневого `.env` файла.
|
||||||
|
|
||||||
|
### Отдельные приложения
|
||||||
|
|
||||||
|
Если нужно запустить отдельные приложения, они также будут использовать корневой `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd play-life-backend
|
||||||
|
docker-compose up
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd play-life-web
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Приоритет переменных окружения
|
||||||
|
|
||||||
|
1. Переменные окружения системы (высший приоритет)
|
||||||
|
2. Локальный `.env` в директории приложения
|
||||||
|
3. Корневой `.env` файл
|
||||||
|
4. Значения по умолчанию в коде
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Изменение порта базы данных
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В .env
|
||||||
|
DB_PORT=5433
|
||||||
|
```
|
||||||
|
|
||||||
|
### Изменение порта бэкенда
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В .env
|
||||||
|
PORT=9090
|
||||||
|
```
|
||||||
|
|
||||||
|
### Изменение порта фронтенда
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# В .env
|
||||||
|
VITE_PORT=4000 # для development
|
||||||
|
WEB_PORT=4001 # для production Docker контейнера
|
||||||
|
```
|
||||||
|
|
||||||
|
После изменения `.env` файла перезапустите соответствующие сервисы.
|
||||||
|
|
||||||
|
## Настройка интеграции с Telegram (webhook для сообщений пользователя)
|
||||||
|
|
||||||
|
Интеграция с Telegram позволяет автоматически обрабатывать сообщения, отправленные пользователем в чат бота, и добавлять их в базу данных play-life.
|
||||||
|
|
||||||
|
### Как это работает
|
||||||
|
|
||||||
|
1. Пользователь отправляет сообщение в чат с ботом в Telegram
|
||||||
|
2. Telegram отправляет webhook на ваш сервер с информацией о сообщении и entities (форматирование)
|
||||||
|
3. Сервер извлекает жирный текст из entities (type === 'bold')
|
||||||
|
4. Парсит жирный текст по формату `project+/-score` (без `**`)
|
||||||
|
5. Обрабатывает текст и сохраняет данные в базу данных
|
||||||
|
6. **НЕ отправляет сообщение обратно в Telegram** (в отличие от других интеграций)
|
||||||
|
|
||||||
|
### Отличия от других интеграций
|
||||||
|
|
||||||
|
- **Формат нод**: `project+/-score` (без `**`), например: `Work+5.5` или `Health-2.0`
|
||||||
|
- **Определение жирного текста**: через entities от Telegram, а не через markdown `**`
|
||||||
|
- **Без обратной отправки**: сообщение не отправляется обратно в Telegram
|
||||||
|
|
||||||
|
### Настройка webhook в Telegram
|
||||||
|
|
||||||
|
#### Автоматическая настройка (рекомендуется)
|
||||||
|
|
||||||
|
1. Создайте бота через [@BotFather](https://t.me/botfather) в Telegram
|
||||||
|
2. Получите токен бота
|
||||||
|
3. Добавьте `WEBHOOK_BASE_URL` в `.env`:
|
||||||
|
```bash
|
||||||
|
WEBHOOK_BASE_URL=https://your-domain.com
|
||||||
|
```
|
||||||
|
4. Откройте приложение и перейдите в раздел "Интеграции" -> "Telegram"
|
||||||
|
5. Введите Bot Token в поле и нажмите "Сохранить"
|
||||||
|
6. Отправьте первое сообщение боту в Telegram - Chat ID будет сохранён автоматически
|
||||||
|
|
||||||
|
**Важно о портах:**
|
||||||
|
- Если сервер доступен на стандартных портах (HTTP 80 или HTTPS 443), порт можно не указывать
|
||||||
|
- Если сервер работает на нестандартном порту и доступен напрямую, укажите порт: `http://your-server:8080`
|
||||||
|
- Если используется reverse proxy (nginx, etc.), указывайте внешний URL без порта: `https://your-domain.com`
|
||||||
|
|
||||||
|
3. Запустите сервер - webhook будет настроен автоматически при старте!
|
||||||
|
|
||||||
|
Для локальной разработки можно использовать ngrok или аналогичный сервис:
|
||||||
|
```bash
|
||||||
|
# Установите ngrok: https://ngrok.com/
|
||||||
|
ngrok http 8080
|
||||||
|
# Используйте полученный URL в WEBHOOK_BASE_URL (без порта)
|
||||||
|
# Например: WEBHOOK_BASE_URL=https://abc123.ngrok.io
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Проверьте логи сервера - должно появиться сообщение:
|
||||||
|
```
|
||||||
|
Telegram webhook configured successfully: https://abc123.ngrok.io/webhook/telegram
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Ручная настройка (если не указан WEBHOOK_BASE_URL)
|
||||||
|
|
||||||
|
Если вы не указали `WEBHOOK_BASE_URL`, webhook нужно настроить вручную:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"url": "http://your-server:8080/webhook/telegram"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверьте, что webhook установлен:
|
||||||
|
```bash
|
||||||
|
curl "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getWebhookInfo"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Формат сообщений в Telegram
|
||||||
|
|
||||||
|
Для корректной обработки сообщения должны содержать жирный текст в формате:
|
||||||
|
```
|
||||||
|
project+/-score
|
||||||
|
```
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
- `Work+5.5` (жирным) - добавить 5.5 баллов к проекту "Work"
|
||||||
|
- `Health-2.0` (жирным) - вычесть 2.0 баллов из проекта "Health"
|
||||||
|
|
||||||
|
**Важно**: Текст должен быть выделен жирным шрифтом в Telegram (через форматирование сообщения, не через `**`).
|
||||||
|
|
||||||
|
### Тестирование
|
||||||
|
|
||||||
|
Для тестирования интеграции:
|
||||||
|
|
||||||
|
1. Откройте чат с вашим ботом в Telegram
|
||||||
|
2. Отправьте сообщение с жирным текстом в формате `project+/-score`, например:
|
||||||
|
- Напишите: `Test message`
|
||||||
|
- Выделите `Work+10.0` жирным шрифтом (через форматирование)
|
||||||
|
- Отправьте сообщение
|
||||||
|
|
||||||
|
3. Проверьте логи сервера - должно появиться сообщение:
|
||||||
|
```
|
||||||
|
Processing Telegram message: text='Test message', entities count=1
|
||||||
|
Successfully processed Telegram message, found 1 nodes
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Проверьте базу данных или веб-интерфейс - данные должны быть добавлены
|
||||||
|
|
||||||
|
### Примечания
|
||||||
|
|
||||||
|
- Webhook должен быть доступен из интернета (для production используйте публичный URL)
|
||||||
|
- Для локальной разработки используйте ngrok или аналогичный сервис для туннелирования
|
||||||
|
- Сообщения обрабатываются только если содержат жирный текст в правильном формате
|
||||||
|
- Сообщения **не отправляются обратно** в Telegram (в отличие от других интеграций)
|
||||||
|
|
||||||
184
IMPACT_ANALYSIS.md
Normal file
184
IMPACT_ANALYSIS.md
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
# Импакт-анализ: Редизайн экрана редактирования доски желаний
|
||||||
|
|
||||||
|
## Дата анализа
|
||||||
|
2025-01-21
|
||||||
|
|
||||||
|
## Созданные компоненты (дизайн-система)
|
||||||
|
|
||||||
|
### 1. `SubmitButton.jsx`
|
||||||
|
- **Путь**: `play-life-web/src/components/SubmitButton.jsx`
|
||||||
|
- **Назначение**: Переиспользуемый компонент кнопки сохранения с градиентным фоном
|
||||||
|
- **Пропсы**: `loading`, `disabled`, `children`, `onClick`, `type`
|
||||||
|
- **Стили**: Градиент от #6366f1 до #8b5cf6, hover эффект с тенью
|
||||||
|
- **Использование**: Заменяет все кнопки сохранения в формах
|
||||||
|
|
||||||
|
### 2. `DeleteButton.jsx`
|
||||||
|
- **Путь**: `play-life-web/src/components/DeleteButton.jsx`
|
||||||
|
- **Назначение**: Переиспользуемый компонент кнопки удаления с красным фоном и иконкой корзины
|
||||||
|
- **Пропсы**: `loading`, `disabled`, `onClick`, `title`
|
||||||
|
- **Стили**: Красный фон #ef4444, квадратная кнопка 44x44px
|
||||||
|
- **Использование**: Заменяет все кнопки удаления в формах
|
||||||
|
|
||||||
|
### 3. `Buttons.css`
|
||||||
|
- **Путь**: `play-life-web/src/components/Buttons.css`
|
||||||
|
- **Назначение**: Общие стили для кнопок дизайн-системы
|
||||||
|
- **Содержимое**:
|
||||||
|
- `.form-actions` - flex-контейнер для группировки кнопок
|
||||||
|
- `.submit-button` - стили для кнопки сохранения
|
||||||
|
- `.delete-button` - стили для кнопки удаления
|
||||||
|
|
||||||
|
## Измененные компоненты
|
||||||
|
|
||||||
|
### 1. `BoardForm.jsx`
|
||||||
|
**Путь**: `play-life-web/src/components/BoardForm.jsx`
|
||||||
|
|
||||||
|
**Изменения**:
|
||||||
|
- ✅ Заменена эмодзи копирования (📋) на SVG иконку в кнопке копирования ссылки
|
||||||
|
- ✅ Удалена кнопка "Отмена" из блока `form-actions`
|
||||||
|
- ✅ Кнопка удаления перемещена в блок `form-actions` справа от кнопки "Сохранить"
|
||||||
|
- ✅ Добавлено состояние `isDeleting` для отслеживания процесса удаления
|
||||||
|
- ✅ Удалена кнопка "Перегенерить ссылку"
|
||||||
|
- ✅ Удалена функция `handleRegenerateLink` (заменена на `generateInviteLink` для внутреннего использования)
|
||||||
|
- ✅ Интегрированы компоненты `SubmitButton` и `DeleteButton`
|
||||||
|
- ✅ Добавлен импорт `Buttons.css`
|
||||||
|
|
||||||
|
**Затронутые места в компоненте**:
|
||||||
|
- Строки 1-5: Добавлены импорты новых компонентов и стилей
|
||||||
|
- Строка 14: Добавлено состояние `isDeleting`
|
||||||
|
- Строки 89-105: Удалена функция `handleRegenerateLink`
|
||||||
|
- Строки 114-132: Обновлена функция `handleToggleInvite` (использует `generateInviteLink`)
|
||||||
|
- Строки 134-151: Обновлена функция `handleDelete` (добавлено состояние `isDeleting`)
|
||||||
|
- Строки 216-222: Заменена эмодзи на SVG иконку копирования
|
||||||
|
- Строки 224-229: Удалена кнопка "Перегенерить ссылку"
|
||||||
|
- Строки 247-258: Обновлен блок `form-actions` (удалена кнопка "Отмена", добавлены новые компоненты)
|
||||||
|
- Строки 261-265: Удален отдельный блок с кнопкой удаления
|
||||||
|
|
||||||
|
### 2. `BoardForm.css`
|
||||||
|
**Путь**: `play-life-web/src/components/BoardForm.css`
|
||||||
|
|
||||||
|
**Изменения**:
|
||||||
|
- ✅ Удалены стили `.regenerate-btn` (строки 128-143)
|
||||||
|
- ✅ Удалены стили `.delete-board-btn` (строки 152-169)
|
||||||
|
- ✅ Стили кнопок теперь импортируются из `Buttons.css`
|
||||||
|
|
||||||
|
**Затронутые места**:
|
||||||
|
- Удалено 42 строки неиспользуемых стилей
|
||||||
|
|
||||||
|
### 3. `TaskForm.jsx`
|
||||||
|
**Путь**: `play-life-web/src/components/TaskForm.jsx`
|
||||||
|
|
||||||
|
**Изменения**:
|
||||||
|
- ✅ Интегрированы компоненты `SubmitButton` и `DeleteButton`
|
||||||
|
- ✅ Добавлен импорт `Buttons.css` (через компоненты)
|
||||||
|
- ✅ Заменены нативные кнопки на компоненты дизайн-системы
|
||||||
|
|
||||||
|
**Затронутые места в компоненте**:
|
||||||
|
- Строки 1-4: Добавлены импорты новых компонентов
|
||||||
|
- Строки 1170-1195: Заменены кнопки на компоненты `SubmitButton` и `DeleteButton`
|
||||||
|
|
||||||
|
## Затронутые экраны
|
||||||
|
|
||||||
|
### 1. Экран редактирования доски желаний (`board-form`)
|
||||||
|
**Компонент**: `BoardForm`
|
||||||
|
**Навигация**: Открывается из экрана списка желаний (`wishlist`) при нажатии на кнопку редактирования доски
|
||||||
|
|
||||||
|
**Изменения в UI**:
|
||||||
|
- ✅ Кнопка копирования ссылки: эмодзи 📋 заменена на SVG иконку (два перекрывающихся прямоугольника)
|
||||||
|
- ✅ При успешном копировании показывается SVG иконка галочки вместо текста ✓
|
||||||
|
- ✅ Удалена кнопка "Отмена" - теперь закрытие происходит только через крестик в правом верхнем углу
|
||||||
|
- ✅ Кнопка "Удалить доску" перемещена в блок действий справа от кнопки "Сохранить"
|
||||||
|
- ✅ Кнопка удаления теперь имеет красный фон и иконку корзины (как в экране редактирования задачи)
|
||||||
|
- ✅ Удалена кнопка "Перегенерить ссылку" - ссылка теперь генерируется автоматически при включении доступа
|
||||||
|
- ✅ Кнопка "Сохранить" имеет градиентный фон и hover эффект (как в экране редактирования задачи)
|
||||||
|
|
||||||
|
**Функциональные изменения**:
|
||||||
|
- Ссылка для приглашения теперь генерируется автоматически при включении переключателя "Разрешить присоединение по ссылке"
|
||||||
|
- Кнопка удаления показывает состояние загрузки (три точки) во время удаления
|
||||||
|
- Кнопка сохранения показывает "Сохранение..." во время процесса сохранения
|
||||||
|
|
||||||
|
**Путь навигации**:
|
||||||
|
- `wishlist` → `board-form` (при нажатии на кнопку редактирования доски)
|
||||||
|
|
||||||
|
### 2. Экран редактирования задачи (`task-form`)
|
||||||
|
**Компонент**: `TaskForm`
|
||||||
|
**Навигация**: Открывается из списка задач (`tasks`) или из деталей желания (`wishlist-detail`)
|
||||||
|
|
||||||
|
**Изменения в UI**:
|
||||||
|
- ✅ Кнопки сохранения и удаления теперь используют компоненты дизайн-системы
|
||||||
|
- ✅ Визуально идентичны кнопкам на экране редактирования доски
|
||||||
|
|
||||||
|
**Функциональные изменения**:
|
||||||
|
- Нет функциональных изменений, только рефакторинг кода
|
||||||
|
|
||||||
|
**Путь навигации**:
|
||||||
|
- `tasks` → `task-form` (при создании/редактировании задачи)
|
||||||
|
- `wishlist-detail` → `task-form` (при создании задачи из желания)
|
||||||
|
|
||||||
|
## Потенциальные места для рефакторинга
|
||||||
|
|
||||||
|
Следующие компоненты используют похожие кнопки и могут быть обновлены для использования новых компонентов дизайн-системы:
|
||||||
|
|
||||||
|
### 1. `WishlistForm.jsx`
|
||||||
|
- **Текущее состояние**: Использует нативную кнопку с классом `submit-button`
|
||||||
|
- **Потенциал**: Можно заменить на `SubmitButton`
|
||||||
|
- **Расположение**: Строки 836-838, 1246-1248
|
||||||
|
|
||||||
|
### 2. `AddWords.jsx`
|
||||||
|
- **Текущее состояние**: Использует нативную кнопку с классом `submit-button`
|
||||||
|
- **Потенциал**: Можно заменить на `SubmitButton`
|
||||||
|
- **Расположение**: Строка 187
|
||||||
|
|
||||||
|
### 3. Другие формы
|
||||||
|
- Компоненты с кнопками удаления могут использовать `DeleteButton`
|
||||||
|
- Компоненты с кнопками сохранения могут использовать `SubmitButton`
|
||||||
|
|
||||||
|
## Файлы, созданные/измененные
|
||||||
|
|
||||||
|
### Созданные файлы:
|
||||||
|
1. `play-life-web/src/components/SubmitButton.jsx` (новый)
|
||||||
|
2. `play-life-web/src/components/DeleteButton.jsx` (новый)
|
||||||
|
3. `play-life-web/src/components/Buttons.css` (новый)
|
||||||
|
|
||||||
|
### Измененные файлы:
|
||||||
|
1. `play-life-web/src/components/BoardForm.jsx` (обновлен)
|
||||||
|
2. `play-life-web/src/components/BoardForm.css` (обновлен)
|
||||||
|
3. `play-life-web/src/components/TaskForm.jsx` (обновлен)
|
||||||
|
|
||||||
|
## Визуальные изменения
|
||||||
|
|
||||||
|
### До изменений:
|
||||||
|
- Эмодзи в кнопке копирования
|
||||||
|
- Кнопка "Отмена" в блоке действий
|
||||||
|
- Кнопка удаления отдельно внизу формы
|
||||||
|
- Кнопка "Перегенерить ссылку" под полем ссылки
|
||||||
|
- Разные стили кнопок в разных формах
|
||||||
|
|
||||||
|
### После изменений:
|
||||||
|
- SVG иконки в кнопке копирования
|
||||||
|
- Только кнопка "Сохранить" и "Удалить" в блоке действий
|
||||||
|
- Кнопка удаления справа от кнопки сохранения
|
||||||
|
- Автоматическая генерация ссылки
|
||||||
|
- Единый стиль кнопок во всех формах (дизайн-система)
|
||||||
|
|
||||||
|
## Технические детали
|
||||||
|
|
||||||
|
### Зависимости
|
||||||
|
- Новые компоненты не добавляют внешних зависимостей
|
||||||
|
- Используют только React и существующие стили
|
||||||
|
|
||||||
|
### Обратная совместимость
|
||||||
|
- ✅ Все изменения обратно совместимы
|
||||||
|
- ✅ Функциональность не нарушена
|
||||||
|
- ✅ API компонентов не изменился
|
||||||
|
|
||||||
|
### Производительность
|
||||||
|
- ✅ Нет влияния на производительность
|
||||||
|
- ✅ Компоненты легковесные
|
||||||
|
- ✅ Стили оптимизированы
|
||||||
|
|
||||||
|
## Рекомендации
|
||||||
|
|
||||||
|
1. **Рефакторинг других форм**: Рассмотреть возможность замены кнопок в `WishlistForm` и `AddWords` на компоненты дизайн-системы
|
||||||
|
2. **Расширение дизайн-системы**: Добавить другие типы кнопок (например, `CancelButton`, `IconButton`)
|
||||||
|
3. **Документация**: Создать документацию по использованию компонентов дизайн-системы
|
||||||
|
4. **Тестирование**: Протестировать все затронутые экраны после развертывания
|
||||||
727
TODOIST_REFACTOR_PLAN.md
Normal file
727
TODOIST_REFACTOR_PLAN.md
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
# План рефакторинга интеграции с Todoist
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
Переделать интеграцию с Todoist для использования **единого приложения**, созданного в Todoist Developer Platform. Все пользователи Play Life используют одно Todoist приложение с единым webhook URL.
|
||||||
|
|
||||||
|
## Текущая реализация
|
||||||
|
- Каждый пользователь имеет уникальный `webhook_token` в таблице `todoist_integrations`
|
||||||
|
- Webhook URL: `/webhook/todoist/{token}` (токен в URL)
|
||||||
|
- Пользователь определяется по токену из URL
|
||||||
|
- Пользователь должен вручную копировать webhook URL
|
||||||
|
|
||||||
|
## Новая реализация (Единое приложение)
|
||||||
|
- **Единое Todoist приложение** для всех пользователей Play Life
|
||||||
|
- **Единый Webhook URL:** `/webhook/todoist` (без токена!)
|
||||||
|
- Webhook настроен в Todoist Developer Console на уровне приложения
|
||||||
|
- Пользователь определяется по `todoist_user_id` из `event_data` webhook
|
||||||
|
- OAuth используется для привязки Todoist аккаунта к Play Life аккаунту
|
||||||
|
- **Пользователю не нужно ничего настраивать** — просто нажать "Подключить Todoist"!
|
||||||
|
|
||||||
|
## Краткое резюме изменений
|
||||||
|
|
||||||
|
### База данных:
|
||||||
|
- **Удалить** поле `webhook_token` (больше не нужно!)
|
||||||
|
- Добавить поля: `todoist_user_id`, `todoist_email`, `access_token`
|
||||||
|
|
||||||
|
### Переменные окружения:
|
||||||
|
- `TODOIST_CLIENT_ID` - Client ID приложения
|
||||||
|
- `TODOIST_CLIENT_SECRET` - Client Secret приложения
|
||||||
|
- `WEBHOOK_BASE_URL` - для формирования OAuth Redirect URI
|
||||||
|
|
||||||
|
### Backend:
|
||||||
|
- **Изменить webhook handler** — идентификация по `todoist_user_id`
|
||||||
|
- Добавить OAuth endpoints для подключения/отключения
|
||||||
|
- Убрать логику с токенами в URL
|
||||||
|
|
||||||
|
### Frontend:
|
||||||
|
- **Убрать отображение webhook URL** (не нужно!)
|
||||||
|
- Показать кнопку "Подключить Todoist"
|
||||||
|
- После подключения показать email и статус
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Изменения в базе данных
|
||||||
|
|
||||||
|
### Миграция: `013_refactor_todoist_single_app.sql`
|
||||||
|
|
||||||
|
**Изменения в таблице `todoist_integrations`:**
|
||||||
|
|
||||||
|
1. **Удалить:**
|
||||||
|
- `webhook_token` — больше не нужен! Webhook единый для всего приложения.
|
||||||
|
|
||||||
|
2. **Добавить:**
|
||||||
|
- `todoist_user_id` (BIGINT) — ID пользователя в Todoist (из OAuth, для идентификации в webhook)
|
||||||
|
- `todoist_email` (VARCHAR(255)) — Email пользователя в Todoist (из OAuth)
|
||||||
|
- `access_token` (TEXT) — OAuth access token (бессрочный в Todoist)
|
||||||
|
|
||||||
|
3. **Индексы:**
|
||||||
|
- **Уникальный** индекс на `todoist_user_id` — ключевой для идентификации в webhook!
|
||||||
|
- Уникальный индекс на `todoist_email`
|
||||||
|
- Удалить индекс на `webhook_token`
|
||||||
|
|
||||||
|
**Структура после миграции:**
|
||||||
|
```sql
|
||||||
|
CREATE TABLE todoist_integrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
todoist_user_id BIGINT, -- ID пользователя в Todoist (КЛЮЧЕВОЕ для webhook!)
|
||||||
|
todoist_email VARCHAR(255), -- Email пользователя в Todoist
|
||||||
|
access_token TEXT, -- OAuth access token (бессрочный)
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT todoist_integrations_user_id_unique UNIQUE (user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Индексы
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_user_id
|
||||||
|
ON todoist_integrations(todoist_user_id)
|
||||||
|
WHERE todoist_user_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_email
|
||||||
|
ON todoist_integrations(todoist_email)
|
||||||
|
WHERE todoist_email IS NOT NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ключевое изменение:** `todoist_user_id` теперь используется для идентификации пользователя при получении webhook от Todoist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Переменные окружения (.env)
|
||||||
|
|
||||||
|
### Добавить в `env.example`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# ============================================
|
||||||
|
# Todoist OAuth Configuration
|
||||||
|
# ============================================
|
||||||
|
# Client ID единого Todoist приложения
|
||||||
|
# Получить в: https://developer.todoist.com/appconsole.html
|
||||||
|
TODOIST_CLIENT_ID=your-todoist-client-id
|
||||||
|
|
||||||
|
# Client Secret единого Todoist приложения
|
||||||
|
TODOIST_CLIENT_SECRET=your-todoist-client-secret
|
||||||
|
|
||||||
|
# Секрет для проверки подлинности webhook от Todoist (опционально)
|
||||||
|
# Если задан, все запросы должны содержать заголовок X-Todoist-Webhook-Secret с этим значением
|
||||||
|
TODOIST_WEBHOOK_SECRET=
|
||||||
|
```
|
||||||
|
|
||||||
|
**Что нужно получить из Todoist приложения:**
|
||||||
|
1. `TODOIST_CLIENT_ID` - Client ID приложения
|
||||||
|
2. `TODOIST_CLIENT_SECRET` - Client Secret приложения
|
||||||
|
3. `TODOIST_WEBHOOK_SECRET` (опционально) - для дополнительной безопасности webhook
|
||||||
|
|
||||||
|
**Важно:** В настройках Todoist приложения нужно указать Redirect URI:
|
||||||
|
- Используйте: `<WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback`
|
||||||
|
- Например, если `WEBHOOK_BASE_URL=https://your-domain.com`, то Redirect URI: `https://your-domain.com/api/integrations/todoist/oauth/callback`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Изменения в Backend (main.go)
|
||||||
|
|
||||||
|
### 3.1. Обновить структуру `TodoistIntegration`:
|
||||||
|
```go
|
||||||
|
type TodoistIntegration struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
UserID int `json:"user_id"`
|
||||||
|
TodoistUserID *int64 `json:"todoist_user_id,omitempty"` // Ключевое для webhook!
|
||||||
|
TodoistEmail *string `json:"todoist_email,omitempty"`
|
||||||
|
AccessToken *string `json:"-"` // Не отдавать в JSON!
|
||||||
|
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:**
|
||||||
|
- `AccessToken` не должен отдаваться в JSON ответах (используйте `json:"-"`)
|
||||||
|
- `TodoistUserID` — ключевое поле для идентификации пользователя в webhook
|
||||||
|
|
||||||
|
### 3.2. Webhook handler (`todoistWebhookHandler`) - КЛЮЧЕВОЕ ИЗМЕНЕНИЕ:
|
||||||
|
|
||||||
|
**Новый подход:**
|
||||||
|
- URL: `/webhook/todoist` (БЕЗ токена!)
|
||||||
|
- Webhook настроен в Todoist Developer Console для всего приложения
|
||||||
|
- Извлекает `user_id` из `event_data` webhook
|
||||||
|
- Находит пользователя по `todoist_user_id`
|
||||||
|
|
||||||
|
**Новая логика:**
|
||||||
|
```go
|
||||||
|
func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// CORS, OPTIONS handling
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
setCORSHeaders(w)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCORSHeaders(w)
|
||||||
|
|
||||||
|
// Проверка webhook secret (если настроен)
|
||||||
|
todoistWebhookSecret := getEnv("TODOIST_WEBHOOK_SECRET", "")
|
||||||
|
if todoistWebhookSecret != "" {
|
||||||
|
providedSecret := r.Header.Get("X-Todoist-Hmac-SHA256")
|
||||||
|
// TODO: проверить HMAC подпись
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим webhook
|
||||||
|
var webhook TodoistWebhook
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&webhook); err != nil {
|
||||||
|
log.Printf("Todoist webhook: error decoding: %v", err)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Todoist webhook: event=%s", webhook.EventName)
|
||||||
|
|
||||||
|
// Обрабатываем только item:completed
|
||||||
|
if webhook.EventName != "item:completed" {
|
||||||
|
log.Printf("Todoist webhook: ignoring event %s", webhook.EventName)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекаем user_id из event_data (это Todoist user_id!)
|
||||||
|
// Может приходить как string или float64
|
||||||
|
var todoistUserID int64
|
||||||
|
switch v := webhook.EventData["user_id"].(type) {
|
||||||
|
case float64:
|
||||||
|
todoistUserID = int64(v)
|
||||||
|
case string:
|
||||||
|
todoistUserID, _ = strconv.ParseInt(v, 10, 64)
|
||||||
|
default:
|
||||||
|
log.Printf("Todoist webhook: user_id not found or invalid type in event_data")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим пользователя Play Life по todoist_user_id
|
||||||
|
var userID int
|
||||||
|
err := a.DB.QueryRow(`
|
||||||
|
SELECT user_id FROM todoist_integrations
|
||||||
|
WHERE todoist_user_id = $1
|
||||||
|
`, todoistUserID).Scan(&userID)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// Пользователь не подключил Play Life — игнорируем
|
||||||
|
log.Printf("Todoist webhook: no user found for todoist_user_id=%d (ignoring)", todoistUserID)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Todoist webhook: DB error: %v", err)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Todoist webhook: todoist_user_id=%d -> user_id=%d", todoistUserID, userID)
|
||||||
|
|
||||||
|
// ... остальная логика обработки события (как раньше) ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3. Маршрут webhook - ИЗМЕНИТЬ:
|
||||||
|
```go
|
||||||
|
// Было:
|
||||||
|
r.HandleFunc("/webhook/todoist/{token}", app.todoistWebhookHandler).Methods("POST", "OPTIONS")
|
||||||
|
|
||||||
|
// Стало:
|
||||||
|
r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:** Этот URL нужно указать в Todoist Developer Console при настройке приложения!
|
||||||
|
|
||||||
|
### 3.4. Добавить OAuth endpoints:
|
||||||
|
|
||||||
|
1. **Инициация OAuth:**
|
||||||
|
- `GET /api/integrations/todoist/oauth/connect` - перенаправляет на Todoist OAuth
|
||||||
|
- **ВАЖНО:** Требует авторизацию пользователя (JWT token в cookie или header)
|
||||||
|
- Генерирует `state` параметр с user_id (JWT подписанный jwtSecret)
|
||||||
|
- Формирует `redirect_uri` из `WEBHOOK_BASE_URL`:
|
||||||
|
```go
|
||||||
|
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
||||||
|
if baseURL == "" {
|
||||||
|
sendErrorWithCORS(w, "WEBHOOK_BASE_URL must be configured", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/todoist/oauth/callback"
|
||||||
|
|
||||||
|
// Генерируем state с user_id
|
||||||
|
state := generateOAuthState(userID, jwtSecret) // JWT с user_id и exp
|
||||||
|
|
||||||
|
// Формируем URL для редиректа
|
||||||
|
authURL := fmt.Sprintf(
|
||||||
|
"https://todoist.com/oauth/authorize?client_id=%s&scope=data:read_write&state=%s&redirect_uri=%s",
|
||||||
|
url.QueryEscape(todoistClientID),
|
||||||
|
url.QueryEscape(state),
|
||||||
|
url.QueryEscape(redirectURI),
|
||||||
|
)
|
||||||
|
|
||||||
|
http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **OAuth callback:**
|
||||||
|
- `GET /api/integrations/todoist/oauth/callback` - обрабатывает callback от Todoist
|
||||||
|
- **ПУБЛИЧНЫЙ ENDPOINT** (без авторизации, так как пользователь приходит от Todoist)
|
||||||
|
- Логика:
|
||||||
|
1. Проверяет `state` параметр (JWT с user_id, exp = 1 день)
|
||||||
|
2. Извлекает `code` из query parameters
|
||||||
|
3. Обменивает `code` на `access_token` через POST запрос к Todoist
|
||||||
|
4. Получает информацию о пользователе через Sync API
|
||||||
|
5. Сохраняет/обновляет данные в БД
|
||||||
|
6. Перенаправляет пользователя на страницу интеграций
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (a *App) todoistOAuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
frontendURL := getEnv("WEBHOOK_BASE_URL", "")
|
||||||
|
redirectSuccess := frontendURL + "/?integration=todoist&status=connected"
|
||||||
|
redirectError := frontendURL + "/?integration=todoist&status=error"
|
||||||
|
|
||||||
|
// 1. Проверяем state (JWT с user_id, exp = 1 день)
|
||||||
|
state := r.URL.Query().Get("state")
|
||||||
|
userID, err := validateOAuthState(state, jwtSecret)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Todoist OAuth: invalid state: %v", err)
|
||||||
|
http.Redirect(w, r, redirectError+"&message=invalid_state", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Получаем code
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
if code == "" {
|
||||||
|
log.Printf("Todoist OAuth: no code in callback")
|
||||||
|
http.Redirect(w, r, redirectError+"&message=no_code", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Обмениваем code на access_token
|
||||||
|
accessToken, err := exchangeCodeForToken(code, redirectURI)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Todoist OAuth: token exchange failed: %v", err)
|
||||||
|
http.Redirect(w, r, redirectError+"&message=token_exchange_failed", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Получаем информацию о пользователе
|
||||||
|
todoistUser, err := getTodoistUserInfo(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Todoist OAuth: get user info failed: %v", err)
|
||||||
|
http.Redirect(w, r, redirectError+"&message=user_info_failed", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Todoist OAuth: user_id=%d connected todoist_user_id=%d email=%s",
|
||||||
|
userID, todoistUser.ID, todoistUser.Email)
|
||||||
|
|
||||||
|
// 5. Сохраняем в БД (INSERT или UPDATE)
|
||||||
|
_, err = a.DB.Exec(`
|
||||||
|
INSERT INTO todoist_integrations (user_id, todoist_user_id, todoist_email, access_token)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
|
todoist_user_id = $2,
|
||||||
|
todoist_email = $3,
|
||||||
|
access_token = $4,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, userID, todoistUser.ID, todoistUser.Email, accessToken)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Todoist OAuth: DB error: %v", err)
|
||||||
|
http.Redirect(w, r, redirectError+"&message=db_error", http.StatusTemporaryRedirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Редирект на страницу интеграций
|
||||||
|
http.Redirect(w, r, redirectSuccess, http.StatusTemporaryRedirect)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Получение статуса интеграции:**
|
||||||
|
- `GET /api/integrations/todoist/status` - возвращает статус подключения
|
||||||
|
- Требует авторизацию (protected endpoint)
|
||||||
|
- Возвращает:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"connected": true,
|
||||||
|
"todoist_email": "user@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
или если не подключено:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"connected": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Примечание:** webhook_url больше не нужен — он единый для всего приложения!
|
||||||
|
|
||||||
|
4. **Отключение интеграции:**
|
||||||
|
- `DELETE /api/integrations/todoist/disconnect` - отключает интеграцию
|
||||||
|
- Требует авторизацию (protected endpoint)
|
||||||
|
- **Удаляет запись** из `todoist_integrations` полностью
|
||||||
|
- Возвращает: `{"success": true, "message": "Todoist disconnected"}`
|
||||||
|
|
||||||
|
### 3.5. Новые маршруты:
|
||||||
|
```go
|
||||||
|
// OAuth endpoints
|
||||||
|
protected.HandleFunc("/api/integrations/todoist/oauth/connect", app.todoistOAuthConnectHandler).Methods("GET")
|
||||||
|
r.HandleFunc("/api/integrations/todoist/oauth/callback", app.todoistOAuthCallbackHandler).Methods("GET") // Публичный!
|
||||||
|
protected.HandleFunc("/api/integrations/todoist/status", app.getTodoistStatusHandler).Methods("GET", "OPTIONS")
|
||||||
|
protected.HandleFunc("/api/integrations/todoist/disconnect", app.todoistDisconnectHandler).Methods("DELETE", "OPTIONS")
|
||||||
|
|
||||||
|
// Webhook (единый для всего приложения)
|
||||||
|
r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS")
|
||||||
|
|
||||||
|
// УДАЛИТЬ старый endpoint:
|
||||||
|
// protected.HandleFunc("/api/integrations/todoist/webhook-url", ...) // Больше не нужен!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:**
|
||||||
|
- OAuth callback должен быть публичным (пользователь приходит от Todoist без JWT)
|
||||||
|
- Webhook тоже публичный (Todoist отправляет события)
|
||||||
|
- `/api/integrations/todoist/webhook-url` — **УДАЛИТЬ**, больше не нужен!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Изменения в Frontend (TodoistIntegration.jsx)
|
||||||
|
|
||||||
|
### 4.1. Добавить проверку статуса подключения:
|
||||||
|
- При загрузке компонента вызывать `GET /api/integrations/todoist/status`
|
||||||
|
- Определять, подключен ли Todoist
|
||||||
|
|
||||||
|
### 4.2. Добавить OAuth flow:
|
||||||
|
- **Если не подключено:**
|
||||||
|
- Показать кнопку "Подключить Todoist"
|
||||||
|
- При клике: `window.location.href = '/api/integrations/todoist/oauth/connect'`
|
||||||
|
- После OAuth callback backend перенаправит на `/?integration=todoist&status=connected`
|
||||||
|
- При загрузке проверять URL параметры и показывать соответствующее сообщение
|
||||||
|
|
||||||
|
- **Если подключено:**
|
||||||
|
- Показать email пользователя Todoist
|
||||||
|
- Показать статус: "✅ Todoist подключен"
|
||||||
|
- Кнопка "Отключить Todoist" (вызывает `DELETE /api/integrations/todoist/disconnect`)
|
||||||
|
- **Webhook URL не нужен** — всё работает автоматически!
|
||||||
|
|
||||||
|
### 4.3. Обновить инструкции:
|
||||||
|
- **Если не подключено:**
|
||||||
|
- Инструкция: "Нажмите кнопку 'Подключить Todoist' для авторизации"
|
||||||
|
|
||||||
|
- **Если подключено:**
|
||||||
|
- Инструкция: "✅ Todoist подключен! Закрывайте задачи в Todoist — они автоматически появятся в Play Life."
|
||||||
|
- **Никаких дополнительных настроек не требуется!**
|
||||||
|
|
||||||
|
### 4.4. Удалить:
|
||||||
|
- Отображение webhook URL
|
||||||
|
- Кнопку "Копировать"
|
||||||
|
- Инструкции по настройке webhook в Todoist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Порядок выполнения изменений
|
||||||
|
|
||||||
|
### Шаг 1: Создать миграцию БД
|
||||||
|
- Создать файл `013_refactor_todoist_single_app.sql`
|
||||||
|
- Содержимое миграции:
|
||||||
|
```sql
|
||||||
|
-- Migration: Refactor todoist_integrations for single Todoist app
|
||||||
|
-- Webhook теперь единый для всего приложения, токены в URL больше не нужны
|
||||||
|
|
||||||
|
-- 1. Добавляем новые поля
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS todoist_user_id BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS todoist_email VARCHAR(255);
|
||||||
|
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS access_token TEXT;
|
||||||
|
|
||||||
|
-- 2. Удаляем webhook_token (больше не нужен!)
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
DROP COLUMN IF EXISTS webhook_token;
|
||||||
|
|
||||||
|
-- 3. Удаляем старый индекс на webhook_token
|
||||||
|
DROP INDEX IF EXISTS idx_todoist_integrations_webhook_token;
|
||||||
|
|
||||||
|
-- 4. Создаем новые индексы
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_user_id
|
||||||
|
ON todoist_integrations(todoist_user_id)
|
||||||
|
WHERE todoist_user_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_email
|
||||||
|
ON todoist_integrations(todoist_email)
|
||||||
|
WHERE todoist_email IS NOT NULL;
|
||||||
|
|
||||||
|
-- 5. Комментарии
|
||||||
|
COMMENT ON COLUMN todoist_integrations.todoist_user_id IS 'Todoist user ID (from OAuth) - used to identify user in webhooks';
|
||||||
|
COMMENT ON COLUMN todoist_integrations.todoist_email IS 'Todoist user email (from OAuth)';
|
||||||
|
COMMENT ON COLUMN todoist_integrations.access_token IS 'Todoist OAuth access token (permanent)';
|
||||||
|
```
|
||||||
|
- Применить миграцию
|
||||||
|
|
||||||
|
**Важно:** После миграции старые записи с `webhook_token` будут работать пока не применится миграция. После миграции все пользователи должны переподключить Todoist через OAuth.
|
||||||
|
|
||||||
|
### Шаг 2: Обновить .env
|
||||||
|
- Добавить новые переменные окружения
|
||||||
|
- Получить данные из Todoist приложения
|
||||||
|
|
||||||
|
### Шаг 3: Обновить Backend
|
||||||
|
- Обновить структуру `TodoistIntegration`
|
||||||
|
- Изменить webhook handler
|
||||||
|
- Добавить OAuth endpoints
|
||||||
|
- Обновить маршруты
|
||||||
|
|
||||||
|
### Шаг 4: Обновить Frontend
|
||||||
|
- Обновить компонент `TodoistIntegration.jsx`
|
||||||
|
- Добавить OAuth flow
|
||||||
|
|
||||||
|
### Шаг 5: Тестирование
|
||||||
|
- Протестировать OAuth flow
|
||||||
|
- Протестировать webhook с новым способом идентификации
|
||||||
|
- Проверить миграцию данных
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Важные замечания
|
||||||
|
|
||||||
|
### 6.1. Идентификация пользователя в webhook
|
||||||
|
**Новый подход:**
|
||||||
|
- Используется `todoist_user_id` из `event_data` webhook
|
||||||
|
- `todoist_user_id` сохраняется при OAuth подключении
|
||||||
|
- Webhook приходит на единый URL `/webhook/todoist`
|
||||||
|
- Находим пользователя Play Life по `todoist_user_id`
|
||||||
|
|
||||||
|
### 6.2. Миграция существующих данных
|
||||||
|
- **Удаляем `webhook_token`** — больше не нужен
|
||||||
|
- Все существующие записи будут работать после миграции, но без OAuth данных
|
||||||
|
- Пользователям нужно **переподключить Todoist через OAuth** для работы интеграции
|
||||||
|
- После миграции старый endpoint `/webhook/todoist/{token}` перестанет работать
|
||||||
|
|
||||||
|
### 6.3. Обратная совместимость
|
||||||
|
- **НЕТ обратной совместимости** — это breaking change
|
||||||
|
- Старый endpoint `/webhook/todoist/{token}` удаляется
|
||||||
|
- Все пользователи должны переподключить Todoist
|
||||||
|
- **Рекомендация:** Уведомить пользователей о необходимости переподключения
|
||||||
|
|
||||||
|
### 6.3.1. Удаляемый код
|
||||||
|
**Удалить полностью:**
|
||||||
|
- Endpoint `GET /api/integrations/todoist/webhook-url`
|
||||||
|
- Handler `getTodoistWebhookURLHandler`
|
||||||
|
- Маршрут `/webhook/todoist/{token}`
|
||||||
|
- Функция генерации webhook_token для Todoist
|
||||||
|
|
||||||
|
### 6.4. Безопасность
|
||||||
|
- OAuth токен (`access_token`) не отдавать в JSON ответах (json:"-")
|
||||||
|
- Использовать `TODOIST_WEBHOOK_SECRET` для проверки подлинности webhook (если настроен в Todoist)
|
||||||
|
- Todoist access_token бессрочный, но пользователь может отозвать его в настройках Todoist
|
||||||
|
- User-Agent для запросов к Todoist API: `PlayLife`
|
||||||
|
|
||||||
|
### 6.5. OAuth Flow (детально)
|
||||||
|
1. Пользователь нажимает "Подключить Todoist"
|
||||||
|
2. Backend генерирует `state` (случайная строка или JWT с user_id) и сохраняет его
|
||||||
|
3. Перенаправление на Todoist OAuth:
|
||||||
|
```
|
||||||
|
https://todoist.com/oauth/authorize?
|
||||||
|
client_id=<TODOIST_CLIENT_ID>&
|
||||||
|
scope=data:read_write&
|
||||||
|
state=<state>&
|
||||||
|
redirect_uri=<WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback
|
||||||
|
```
|
||||||
|
4. Пользователь авторизуется в Todoist
|
||||||
|
5. Todoist перенаправляет на `redirect_uri` с `code` и `state`
|
||||||
|
6. Backend проверяет `state` и обменивает `code` на `access_token`:
|
||||||
|
```
|
||||||
|
POST https://todoist.com/oauth/access_token
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
|
||||||
|
client_id=<TODOIST_CLIENT_ID>&
|
||||||
|
client_secret=<TODOIST_CLIENT_SECRET>&
|
||||||
|
code=<code>&
|
||||||
|
redirect_uri=<redirect_uri>
|
||||||
|
```
|
||||||
|
7. Backend получает информацию о пользователе через Todoist Sync API:
|
||||||
|
```
|
||||||
|
POST https://api.todoist.com/sync/v9/sync
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
User-Agent: PlayLife
|
||||||
|
|
||||||
|
sync_token=*&resource_types=["user"]
|
||||||
|
```
|
||||||
|
Ответ содержит `user.id` и `user.email`
|
||||||
|
8. Backend сохраняет `todoist_user_id`, `todoist_email`, `access_token` в БД
|
||||||
|
9. Перенаправление пользователя на страницу интеграций
|
||||||
|
|
||||||
|
### 6.6. Хранение state для OAuth
|
||||||
|
Используем JWT токен (не требует хранения в БД):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Генерация state (таймаут = 1 день)
|
||||||
|
func generateOAuthState(userID int, jwtSecret string) string {
|
||||||
|
state := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
|
"user_id": userID,
|
||||||
|
"type": "todoist_oauth",
|
||||||
|
"exp": time.Now().Add(24 * time.Hour).Unix(), // 1 день
|
||||||
|
})
|
||||||
|
stateString, _ := state.SignedString([]byte(jwtSecret))
|
||||||
|
return stateString
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка state в callback
|
||||||
|
func validateOAuthState(stateString string, jwtSecret string) (int, error) {
|
||||||
|
token, err := jwt.Parse(stateString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(jwtSecret), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(jwt.MapClaims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return 0, fmt.Errorf("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if claims["type"] != "todoist_oauth" {
|
||||||
|
return 0, fmt.Errorf("wrong token type")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := int(claims["user_id"].(float64))
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.7. Особенности Todoist OAuth
|
||||||
|
- **Scope:** `data:read_write` — полный доступ к данным пользователя
|
||||||
|
- **Access Token:** Todoist выдает бессрочный access_token
|
||||||
|
- **Refresh Token:** Todoist НЕ использует refresh_token
|
||||||
|
- **Отзыв токена:** Пользователь может отозвать доступ в настройках Todoist
|
||||||
|
|
||||||
|
### 6.8. Обработка ошибок
|
||||||
|
|
||||||
|
**Если todoist_user_id не найден в webhook:**
|
||||||
|
- Логировать: `log.Printf("Todoist webhook: no user found for todoist_user_id=%d", todoistUserID)`
|
||||||
|
- Возвращать `200 OK` (чтобы Todoist не делал retry)
|
||||||
|
- Игнорировать событие
|
||||||
|
|
||||||
|
**Если токен отозван пользователем:**
|
||||||
|
- При попытке использовать access_token Todoist вернет ошибку
|
||||||
|
- Автоматически отключить интеграцию (удалить запись из БД)
|
||||||
|
- Логировать: `log.Printf("Todoist: token revoked for user_id=%d, disconnecting", userID)`
|
||||||
|
|
||||||
|
**При disconnect:**
|
||||||
|
- Просто удалить запись из БД
|
||||||
|
- НЕ отзывать токен через Todoist API (упрощение)
|
||||||
|
|
||||||
|
### 6.9. События Todoist
|
||||||
|
Подписываемся только на: **`item:completed`**
|
||||||
|
|
||||||
|
Другие события (`item:added`, `item:updated`, `item:deleted`) не нужны.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Архитектура: Единый Webhook
|
||||||
|
|
||||||
|
**Ключевое решение:** Используем единый webhook URL для всего приложения.
|
||||||
|
|
||||||
|
### Как это работает:
|
||||||
|
|
||||||
|
1. **Настройка в Todoist Developer Console:**
|
||||||
|
- Создать приложение в https://developer.todoist.com/appconsole.html
|
||||||
|
- Указать Webhook URL: `<WEBHOOK_BASE_URL>/webhook/todoist`
|
||||||
|
- Указать OAuth Redirect URI: `<WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback`
|
||||||
|
- Выбрать события: `item:completed`
|
||||||
|
|
||||||
|
2. **При OAuth подключении:**
|
||||||
|
- Пользователь нажимает "Подключить Todoist"
|
||||||
|
- Авторизуется в Todoist
|
||||||
|
- Play Life получает `access_token` и информацию о пользователе
|
||||||
|
- Сохраняем `todoist_user_id` — это ключ для идентификации в webhook
|
||||||
|
|
||||||
|
3. **При получении webhook:**
|
||||||
|
- Todoist отправляет POST на `/webhook/todoist`
|
||||||
|
- В `event_data` есть `user_id` (это Todoist user_id)
|
||||||
|
- Находим пользователя Play Life по `todoist_user_id`
|
||||||
|
- Обрабатываем событие
|
||||||
|
|
||||||
|
### Преимущества:
|
||||||
|
- ✅ Пользователю не нужно ничего настраивать!
|
||||||
|
- ✅ Нет токенов в URL
|
||||||
|
- ✅ Простая архитектура
|
||||||
|
- ✅ Webhook настраивается один раз в Developer Console
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Настройка Todoist приложения в Developer Console
|
||||||
|
|
||||||
|
### Шаги настройки:
|
||||||
|
1. Зайти в https://developer.todoist.com/appconsole.html
|
||||||
|
2. Создать новое приложение или открыть существующее
|
||||||
|
3. Заполнить:
|
||||||
|
- **App name:** Play Life
|
||||||
|
- **App description:** Интеграция с Play Life для отслеживания прогресса
|
||||||
|
- **OAuth Redirect URL:** `<WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback`
|
||||||
|
- **Webhooks callback URL:** `<WEBHOOK_BASE_URL>/webhook/todoist`
|
||||||
|
- **Watched events:** `item:completed` (только это событие!)
|
||||||
|
4. Скопировать:
|
||||||
|
- **Client ID** → `TODOIST_CLIENT_ID`
|
||||||
|
- **Client Secret** → `TODOIST_CLIENT_SECRET`
|
||||||
|
- **Client secret for webhooks** (если есть) → `TODOIST_WEBHOOK_SECRET`
|
||||||
|
|
||||||
|
### Важные настройки:
|
||||||
|
- **OAuth scope:** `data:read_write`
|
||||||
|
- **Watched events:** только `item:completed`
|
||||||
|
- Другие события НЕ подписывать
|
||||||
|
|
||||||
|
### Формат webhook от Todoist:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event_name": "item:completed",
|
||||||
|
"user_id": "12345678", // ← Это todoist_user_id для идентификации!
|
||||||
|
"event_data": {
|
||||||
|
"id": "task_id",
|
||||||
|
"content": "Task title",
|
||||||
|
"description": "Task description",
|
||||||
|
"user_id": "12345678", // ← Тоже здесь
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:** `user_id` приходит как string, нужно конвертировать в int64.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Краткая сводка для быстрого старта
|
||||||
|
|
||||||
|
### Настройка Todoist приложения:
|
||||||
|
1. Зайти в https://developer.todoist.com/appconsole.html
|
||||||
|
2. Создать приложение
|
||||||
|
3. Настроить:
|
||||||
|
- **OAuth Redirect URL:** `<WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback`
|
||||||
|
- **Webhooks callback URL:** `<WEBHOOK_BASE_URL>/webhook/todoist`
|
||||||
|
- **Watched events:** `item:completed`
|
||||||
|
4. Скопировать Client ID и Client Secret
|
||||||
|
|
||||||
|
### Что добавить в .env:
|
||||||
|
```env
|
||||||
|
TODOIST_CLIENT_ID=your-client-id-here
|
||||||
|
TODOIST_CLIENT_SECRET=your-client-secret-here
|
||||||
|
TODOIST_WEBHOOK_SECRET= # опционально, из Developer Console
|
||||||
|
```
|
||||||
|
|
||||||
|
### Что изменится в базе данных:
|
||||||
|
- Добавятся поля: `todoist_user_id`, `todoist_email`, `access_token`
|
||||||
|
- **Удалится поле:** `webhook_token`
|
||||||
|
|
||||||
|
### Что изменится для пользователей:
|
||||||
|
- Пользователи нажимают "Подключить Todoist"
|
||||||
|
- Авторизуются в Todoist
|
||||||
|
- **Готово!** Никаких дополнительных настроек!
|
||||||
|
- Закрытые задачи в Todoist автоматически появляются в Play Life
|
||||||
|
|
||||||
|
### Порядок реализации:
|
||||||
|
1. ⬜ Настроить Todoist приложение в Developer Console
|
||||||
|
2. ⬜ Создать миграцию БД (`013_refactor_todoist_single_app.sql`)
|
||||||
|
3. ⬜ Обновить `.env` с новыми переменными
|
||||||
|
4. ⬜ Реализовать OAuth endpoints в Backend
|
||||||
|
5. ⬜ Обновить webhook handler (идентификация по todoist_user_id)
|
||||||
|
6. ⬜ Обновить Frontend компонент
|
||||||
|
7. ⬜ Удалить старый код (webhook-url endpoint, токены)
|
||||||
|
8. ⬜ Протестировать OAuth flow и webhook
|
||||||
|
|
||||||
25
build-and-save.ps1
Normal file
25
build-and-save.ps1
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# PowerShell скрипт для сборки единого Docker образа и сохранения в tar
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$IMAGE_NAME = "play-life-unified"
|
||||||
|
$IMAGE_TAG = if ($env:IMAGE_TAG) { $env:IMAGE_TAG } else { "latest" }
|
||||||
|
$TAR_FILE = if ($env:TAR_FILE) { $env:TAR_FILE } else { "play-life-unified.tar" }
|
||||||
|
|
||||||
|
Write-Host "🔨 Сборка единого Docker образа..." -ForegroundColor Cyan
|
||||||
|
docker build -t "${IMAGE_NAME}:${IMAGE_TAG}" .
|
||||||
|
|
||||||
|
Write-Host "💾 Сохранение образа в tar файл..." -ForegroundColor Cyan
|
||||||
|
docker save "${IMAGE_NAME}:${IMAGE_TAG}" -o "${TAR_FILE}"
|
||||||
|
|
||||||
|
$fileSize = (Get-Item "${TAR_FILE}").Length / 1MB
|
||||||
|
Write-Host "✅ Образ успешно сохранен в ${TAR_FILE}" -ForegroundColor Green
|
||||||
|
Write-Host "📦 Размер файла: $([math]::Round($fileSize, 2)) MB" -ForegroundColor Green
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Для загрузки образа на другой машине используйте:" -ForegroundColor Yellow
|
||||||
|
Write-Host " docker load -i ${TAR_FILE}" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Для запуска контейнера используйте:" -ForegroundColor Yellow
|
||||||
|
Write-Host " docker run -d -p 80:80 --env-file .env ${IMAGE_NAME}:${IMAGE_TAG}" -ForegroundColor White
|
||||||
|
|
||||||
26
build-and-save.sh
Normal file
26
build-and-save.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт для сборки единого Docker образа и сохранения в tar
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
IMAGE_NAME="play-life-unified"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||||
|
TAR_FILE="${TAR_FILE:-play-life-unified.tar}"
|
||||||
|
|
||||||
|
echo "🔨 Сборка единого Docker образа..."
|
||||||
|
docker build -t "${IMAGE_NAME}:${IMAGE_TAG}" .
|
||||||
|
|
||||||
|
echo "💾 Сохранение образа в tar файл..."
|
||||||
|
docker save "${IMAGE_NAME}:${IMAGE_TAG}" -o "${TAR_FILE}"
|
||||||
|
|
||||||
|
echo "✅ Образ успешно сохранен в ${TAR_FILE}"
|
||||||
|
echo "📦 Размер файла: $(du -h ${TAR_FILE} | cut -f1)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Для загрузки образа на другой машине используйте:"
|
||||||
|
echo " docker load -i ${TAR_FILE}"
|
||||||
|
echo ""
|
||||||
|
echo "Для запуска контейнера используйте:"
|
||||||
|
echo " docker run -d -p 80:80 --env-file .env ${IMAGE_NAME}:${IMAGE_TAG}"
|
||||||
|
|
||||||
75
check-repo-fs.sh
Executable file
75
check-repo-fs.sh
Executable file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Скрипт для проверки файловой системы репозитория Gitea
|
||||||
|
# Выполните на сервере с административным доступом
|
||||||
|
|
||||||
|
REPO_PATH="/poignatov/play-life.git"
|
||||||
|
GITEA_USER="git" # или пользователь, под которым работает Gitea
|
||||||
|
|
||||||
|
echo "=== Проверка существования репозитория ==="
|
||||||
|
if [ -d "$REPO_PATH" ]; then
|
||||||
|
echo "✓ Репозиторий существует"
|
||||||
|
else
|
||||||
|
echo "✗ Репозиторий НЕ найден: $REPO_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Проверка прав доступа ==="
|
||||||
|
ls -ld "$REPO_PATH"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Проверка владельца ==="
|
||||||
|
OWNER=$(stat -c '%U:%G' "$REPO_PATH" 2>/dev/null || stat -f '%Su:%Sg' "$REPO_PATH" 2>/dev/null)
|
||||||
|
echo "Владелец: $OWNER"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Проверка размера репозитория ==="
|
||||||
|
du -sh "$REPO_PATH"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Проверка свободного места ==="
|
||||||
|
df -h "$REPO_PATH" | tail -1
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Проверка ключевых файлов Git ==="
|
||||||
|
if [ -f "$REPO_PATH/config" ]; then
|
||||||
|
echo "✓ config существует"
|
||||||
|
else
|
||||||
|
echo "✗ config НЕ найден"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "$REPO_PATH/objects" ]; then
|
||||||
|
echo "✓ objects/ существует"
|
||||||
|
echo " Количество объектов: $(find "$REPO_PATH/objects" -type f | wc -l)"
|
||||||
|
else
|
||||||
|
echo "✗ objects/ НЕ найден"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$REPO_PATH/HEAD" ]; then
|
||||||
|
echo "✓ HEAD существует"
|
||||||
|
echo " Текущая ветка: $(cat "$REPO_PATH/HEAD")"
|
||||||
|
else
|
||||||
|
echo "✗ HEAD НЕ найден"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$REPO_PATH/refs/heads/main" ]; then
|
||||||
|
echo "✓ refs/heads/main существует"
|
||||||
|
echo " Последний коммит: $(cat "$REPO_PATH/refs/heads/main")"
|
||||||
|
else
|
||||||
|
echo "✗ refs/heads/main НЕ найден"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Проверка целостности репозитория ==="
|
||||||
|
cd "$REPO_PATH"
|
||||||
|
if git fsck --no-progress 2>&1 | head -20; then
|
||||||
|
echo "✓ Репозиторий цел"
|
||||||
|
else
|
||||||
|
echo "✗ Обнаружены проблемы с целостностью"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Проверка логов Gitea ==="
|
||||||
|
echo "Проверьте логи Gitea на наличие ошибок:"
|
||||||
|
echo " - /var/log/gitea/gitea.log"
|
||||||
|
echo " - или в директории, указанной в конфиге Gitea"
|
||||||
0
database-dumps/.gitkeep
Normal file
0
database-dumps/.gitkeep
Normal file
59
database-dumps/README.md
Normal file
59
database-dumps/README.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Database Dumps
|
||||||
|
|
||||||
|
Эта директория содержит дампы базы данных для разработки и тестирования.
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Создание дампа
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Дамп из БД (по умолчанию .env)
|
||||||
|
./dump-db.sh
|
||||||
|
|
||||||
|
# Дамп с именем
|
||||||
|
./dump-db.sh production-backup
|
||||||
|
|
||||||
|
# Дамп из другого окружения
|
||||||
|
./dump-db.sh --env-file .env.prod my-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Просмотр дампов
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./list-dumps.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Восстановление дампа
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Восстановление в БД (по умолчанию .env)
|
||||||
|
./restore-db.sh dump_20240101_120000.sql.gz
|
||||||
|
|
||||||
|
# Восстановление в другое окружение
|
||||||
|
./restore-db.sh --env-file .env.prod dump_20240101_120000.sql.gz
|
||||||
|
|
||||||
|
# Можно указать имя без расширения
|
||||||
|
./restore-db.sh dump_20240101_120000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Поведение по умолчанию
|
||||||
|
|
||||||
|
- **Создание дампа**: использует `.env`
|
||||||
|
- **Восстановление**: использует `.env`
|
||||||
|
|
||||||
|
Это можно изменить с помощью параметра `--env-file`.
|
||||||
|
|
||||||
|
## Важно
|
||||||
|
|
||||||
|
⚠️ **Восстановление дампа удалит все данные в целевой базе данных!**
|
||||||
|
|
||||||
|
Всегда проверяйте, в какую БД вы восстанавливаете данные.
|
||||||
|
|
||||||
|
## Формат файлов
|
||||||
|
|
||||||
|
Дампы сохраняются в формате:
|
||||||
|
- `dump_YYYYMMDD_HHMMSS.sql.gz` - автоматическое имя с датой/временем
|
||||||
|
- `имя_дампа.sql.gz` - именованный дамп
|
||||||
|
|
||||||
|
Все дампы автоматически сжимаются с помощью gzip.
|
||||||
|
|
||||||
27
docker-compose.prod.yml
Normal file
27
docker-compose.prod.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
# Production конфигурация для Synology
|
||||||
|
# Использует образ из registry вместо локальной сборки
|
||||||
|
# База данных postgres запущена отдельно (не в этом compose)
|
||||||
|
|
||||||
|
services:
|
||||||
|
play-life:
|
||||||
|
image: dungeonsiege.synology.me/poignatov/play-life:latest
|
||||||
|
container_name: play-life-prod
|
||||||
|
ports:
|
||||||
|
- "3080:80"
|
||||||
|
volumes:
|
||||||
|
- /volume1/docker/play-life/uploads:/app/uploads:rw
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
# Подключаемся к общей сети playlife-net
|
||||||
|
# Перед первым запуском нужно создать сеть и подключить postgres:
|
||||||
|
# docker network create playlife-net
|
||||||
|
# docker network connect playlife-net postgres1
|
||||||
|
networks:
|
||||||
|
- playlife-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
playlife-net:
|
||||||
|
external: true
|
||||||
83
docker-compose.yml
Normal file
83
docker-compose.yml
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
# Единый docker-compose для всех приложений в одном образе
|
||||||
|
# Использует корневой .env файл
|
||||||
|
|
||||||
|
services:
|
||||||
|
# База данных PostgreSQL
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER:-playeng}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-playeng}
|
||||||
|
POSTGRES_DB: ${DB_NAME:-playeng}
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
# Backend сервер (Go)
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./play-life-backend/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8080}:8080"
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_USER: ${DB_USER:-playeng}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD:-playeng}
|
||||||
|
DB_NAME: ${DB_NAME:-playeng}
|
||||||
|
PORT: ${PORT:-8080}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./play-life-backend/migrations:/migrations
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
# Frontend приложение play-life-web
|
||||||
|
play-life-web:
|
||||||
|
build:
|
||||||
|
context: ./play-life-web
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: play-life-web
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-3001}:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
# LLM сервис (Ollama + Tavily), свой Docker и свой env
|
||||||
|
llm:
|
||||||
|
build:
|
||||||
|
context: ./play-life-llm
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: play-life-llm
|
||||||
|
ports:
|
||||||
|
- "8090:8090"
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- ./play-life-llm/.env
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
name: play-life_postgres_data
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: play-life-network
|
||||||
|
|
||||||
138
dump-db.sh
Executable file
138
dump-db.sh
Executable file
@@ -0,0 +1,138 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт для создания дампа базы данных
|
||||||
|
# Использование:
|
||||||
|
# ./dump-db.sh [имя_дампа] # Дамп из .env.prod
|
||||||
|
# ./dump-db.sh --env-file .env [имя] # Дамп из указанного файла
|
||||||
|
# ./dump-db.sh production-backup # Именованный дамп из .env.prod
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Значения по умолчанию
|
||||||
|
DEFAULT_ENV_FILE=".env.prod"
|
||||||
|
ENV_FILE="$DEFAULT_ENV_FILE"
|
||||||
|
DUMP_NAME=""
|
||||||
|
|
||||||
|
# Парсим аргументы
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--env-file)
|
||||||
|
ENV_FILE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [ -z "$DUMP_NAME" ]; then
|
||||||
|
DUMP_NAME="$1"
|
||||||
|
else
|
||||||
|
echo "❌ Ошибка: Неизвестный аргумент: $1"
|
||||||
|
echo "Использование: ./dump-db.sh [--env-file FILE] [имя_дампа]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Загружаем переменные окружения из указанного файла
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
export $(cat "$ENV_FILE" | grep -v '^#' | grep -v '^$' | xargs)
|
||||||
|
echo "📋 Используется файл окружения: $ENV_FILE"
|
||||||
|
else
|
||||||
|
echo "⚠️ Файл $ENV_FILE не найден, используются значения по умолчанию"
|
||||||
|
fi
|
||||||
|
|
||||||
|
DB_HOST=${DB_HOST:-localhost}
|
||||||
|
DB_PORT=${DB_PORT:-5432}
|
||||||
|
DB_USER=${DB_USER:-playeng}
|
||||||
|
DB_PASSWORD=${DB_PASSWORD:-playeng}
|
||||||
|
DB_NAME=${DB_NAME:-playeng}
|
||||||
|
|
||||||
|
# Создаем директорию для дампов, если её нет
|
||||||
|
mkdir -p database-dumps
|
||||||
|
|
||||||
|
# Генерируем имя файла с датой и временем, если не указано
|
||||||
|
if [ -z "$DUMP_NAME" ]; then
|
||||||
|
DUMP_NAME="dump_$(date +%Y%m%d_%H%M%S).sql"
|
||||||
|
else
|
||||||
|
DUMP_NAME="$DUMP_NAME.sql"
|
||||||
|
fi
|
||||||
|
|
||||||
|
DUMP_PATH="database-dumps/$DUMP_NAME"
|
||||||
|
|
||||||
|
echo "🗄️ Создание дампа базы данных..."
|
||||||
|
echo " База: $DB_NAME"
|
||||||
|
echo " Хост: $DB_HOST:$DB_PORT"
|
||||||
|
echo " Пользователь: $DB_USER"
|
||||||
|
echo " Файл: $DUMP_PATH"
|
||||||
|
|
||||||
|
# Создаем дамп через docker-compose, если контейнер запущен И хост локальный
|
||||||
|
if [ "$DB_HOST" = "localhost" ] || [ "$DB_HOST" = "127.0.0.1" ] || [ -z "$DB_HOST" ]; then
|
||||||
|
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
|
||||||
|
echo " Используется docker-compose..."
|
||||||
|
docker-compose exec -T db pg_dump -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
||||||
|
elif command -v pg_dump &> /dev/null; then
|
||||||
|
# Или напрямую через pg_dump, если БД доступна локально
|
||||||
|
echo " Используется локальный pg_dump..."
|
||||||
|
PGPASSWORD="$DB_PASSWORD" pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
||||||
|
elif command -v docker &> /dev/null; then
|
||||||
|
# Используем Docker образ postgres для создания дампа
|
||||||
|
echo " Используется Docker (postgres:latest)..."
|
||||||
|
docker run --rm -i --network host \
|
||||||
|
-e PGPASSWORD="$DB_PASSWORD" \
|
||||||
|
postgres:latest \
|
||||||
|
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
||||||
|
else
|
||||||
|
echo "❌ Ошибка: pg_dump не найден, docker-compose не запущен и Docker недоступен"
|
||||||
|
echo " Установите PostgreSQL клиент или Docker"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Для удаленных хостов используем pg_dump или Docker
|
||||||
|
if command -v pg_dump &> /dev/null; then
|
||||||
|
echo " Используется локальный pg_dump..."
|
||||||
|
PGPASSWORD="$DB_PASSWORD" pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
||||||
|
elif command -v docker &> /dev/null; then
|
||||||
|
# Используем Docker образ postgres для создания дампа
|
||||||
|
# Используем latest для совместимости с разными версиями сервера
|
||||||
|
echo " Используется Docker (postgres:latest)..."
|
||||||
|
# Используем --network host для доступа к удаленным хостам
|
||||||
|
docker run --rm -i --network host \
|
||||||
|
-e PGPASSWORD="$DB_PASSWORD" \
|
||||||
|
postgres:latest \
|
||||||
|
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
||||||
|
else
|
||||||
|
echo "❌ Ошибка: pg_dump не найден и Docker недоступен"
|
||||||
|
echo " Установите PostgreSQL клиент или Docker"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Сжимаем дамп
|
||||||
|
echo " Сжатие дампа..."
|
||||||
|
gzip -f "$DUMP_PATH"
|
||||||
|
DUMP_PATH="${DUMP_PATH}.gz"
|
||||||
|
|
||||||
|
echo "✅ Дамп успешно создан: $DUMP_PATH"
|
||||||
|
echo " Размер: $(du -h "$DUMP_PATH" | cut -f1)"
|
||||||
|
|
||||||
|
# Ограничиваем количество дампов (максимум 10)
|
||||||
|
MAX_DUMPS=10
|
||||||
|
DUMP_COUNT=$(ls -1 database-dumps/*.sql.gz 2>/dev/null | wc -l | tr -d ' ')
|
||||||
|
|
||||||
|
if [ "$DUMP_COUNT" -gt "$MAX_DUMPS" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "🧹 Очистка старых дампов (максимум $MAX_DUMPS)..."
|
||||||
|
# Сортируем по дате модификации (новые первыми) и удаляем самые старые
|
||||||
|
OLD_DUMPS=$(ls -1t database-dumps/*.sql.gz 2>/dev/null | tail -n +$((MAX_DUMPS + 1)))
|
||||||
|
if [ -n "$OLD_DUMPS" ]; then
|
||||||
|
REMOVED_COUNT=0
|
||||||
|
for old_dump in $OLD_DUMPS; do
|
||||||
|
rm -f "$old_dump"
|
||||||
|
REMOVED_COUNT=$((REMOVED_COUNT + 1))
|
||||||
|
echo " Удален: $(basename "$old_dump")"
|
||||||
|
done
|
||||||
|
echo " Удалено дампов: $REMOVED_COUNT"
|
||||||
|
echo " Осталось дампов: $MAX_DUMPS"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
104
env.example
Normal file
104
env.example
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# ============================================
|
||||||
|
# Единый файл конфигурации для всех проектов
|
||||||
|
# Backend и Play-Life-Web
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Database Configuration
|
||||||
|
# ============================================
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=playeng
|
||||||
|
DB_PASSWORD=playeng
|
||||||
|
DB_NAME=playeng
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Backend Server Configuration
|
||||||
|
# ============================================
|
||||||
|
# Порт для backend сервера (по умолчанию: 8080)
|
||||||
|
# В production всегда используется порт 8080 внутри контейнера
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Play Life Web Configuration
|
||||||
|
# ============================================
|
||||||
|
# Порт для frontend приложения play-life-web
|
||||||
|
WEB_PORT=3001
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Telegram Bot Configuration
|
||||||
|
# ============================================
|
||||||
|
# Токен единого бота для всех пользователей
|
||||||
|
# Получить у @BotFather: https://t.me/botfather
|
||||||
|
TELEGRAM_BOT_TOKEN=your-bot-token-here
|
||||||
|
|
||||||
|
# Base URL для автоматической настройки webhook
|
||||||
|
# Примеры:
|
||||||
|
# - Для production с HTTPS: https://your-domain.com
|
||||||
|
# - Для локальной разработки с ngrok: https://abc123.ngrok.io
|
||||||
|
# - Для прямого доступа на нестандартном порту: http://your-server:8080
|
||||||
|
# Webhook будет настроен автоматически при старте сервера на: <WEBHOOK_BASE_URL>/webhook/telegram
|
||||||
|
# Если не указан, webhook нужно настраивать вручную
|
||||||
|
WEBHOOK_BASE_URL=https://your-domain.com
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Todoist Integration Configuration
|
||||||
|
# ============================================
|
||||||
|
# Единое Todoist приложение для всех пользователей Play Life
|
||||||
|
# Настроить в: https://developer.todoist.com/appconsole.html
|
||||||
|
#
|
||||||
|
# В настройках Todoist приложения указать:
|
||||||
|
# - OAuth Redirect URL: <WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback
|
||||||
|
# - Webhooks callback URL: <WEBHOOK_BASE_URL>/webhook/todoist
|
||||||
|
# - Watched events: item:completed
|
||||||
|
|
||||||
|
# Client ID единого Todoist приложения
|
||||||
|
TODOIST_CLIENT_ID=
|
||||||
|
|
||||||
|
# Client Secret единого Todoist приложения
|
||||||
|
TODOIST_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Секрет для проверки подлинности webhook от Todoist (опционально)
|
||||||
|
# Получить в Developer Console: "Client secret for webhooks"
|
||||||
|
TODOIST_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Fitbit Integration Configuration
|
||||||
|
# ============================================
|
||||||
|
# Fitbit приложение для интеграции с Play Life
|
||||||
|
# Настроить в: https://dev.fitbit.com/apps
|
||||||
|
#
|
||||||
|
# В настройках Fitbit приложения указать:
|
||||||
|
# - OAuth 2.0 Application Type: Server
|
||||||
|
# - Callback URL: <WEBHOOK_BASE_URL>/api/integrations/fitbit/oauth/callback
|
||||||
|
# - Default Access Type: Read-Only
|
||||||
|
# - Scopes: activity, profile
|
||||||
|
# - Terms of Service URL: <WEBHOOK_BASE_URL>/terms
|
||||||
|
# - Privacy Policy URL: <WEBHOOK_BASE_URL>/privacy
|
||||||
|
|
||||||
|
# Client ID Fitbit приложения
|
||||||
|
FITBIT_CLIENT_ID=
|
||||||
|
|
||||||
|
# Client Secret Fitbit приложения
|
||||||
|
FITBIT_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Authentication Configuration
|
||||||
|
# ============================================
|
||||||
|
# Секретный ключ для подписи JWT токенов
|
||||||
|
# ВАЖНО: Обязательно задайте свой уникальный секретный ключ для production!
|
||||||
|
# Если не задан, будет использован случайно сгенерированный (не рекомендуется для production)
|
||||||
|
# Можно сгенерировать с помощью: openssl rand -base64 32
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Scheduler Configuration
|
||||||
|
# ============================================
|
||||||
|
# Часовой пояс для планировщика задач (например: Europe/Moscow, America/New_York, UTC)
|
||||||
|
# Используется для:
|
||||||
|
# - Автоматической фиксации целей на неделю каждый понедельник в 6:00
|
||||||
|
# - Отправки ежедневного отчёта в 23:59
|
||||||
|
# ВАЖНО: Укажите правильный часовой пояс, иначе задачи будут срабатывать в UTC!
|
||||||
|
# Список доступных часовых поясов: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||||
|
TIMEZONE=Europe/Moscow
|
||||||
|
|
||||||
160
init.sh
Executable file
160
init.sh
Executable file
@@ -0,0 +1,160 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт для первоначальной настройки и запуска приложения
|
||||||
|
# Использование: ./init.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Цвета для вывода
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Проверка наличия .env файла
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo -e "${RED}❌ Файл .env не найден!${NC}"
|
||||||
|
echo " Создайте файл .env на основе env.example"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Загружаем переменные окружения
|
||||||
|
export $(cat .env | grep -v '^#' | grep -v '^$' | xargs)
|
||||||
|
|
||||||
|
# Значения по умолчанию
|
||||||
|
DB_USER=${DB_USER:-playeng}
|
||||||
|
DB_PASSWORD=${DB_PASSWORD:-playeng}
|
||||||
|
DB_NAME=${DB_NAME:-playeng}
|
||||||
|
DB_PORT=${DB_PORT:-5432}
|
||||||
|
PORT=${PORT:-8080}
|
||||||
|
WEB_PORT=${WEB_PORT:-3001}
|
||||||
|
|
||||||
|
echo -e "${GREEN}🚀 Инициализация Play Life...${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Остановка и удаление существующих контейнеров
|
||||||
|
echo -e "${YELLOW}1. Остановка существующих контейнеров...${NC}"
|
||||||
|
docker-compose down -v 2>/dev/null || true
|
||||||
|
echo -e "${GREEN} ✅ Контейнеры остановлены${NC}"
|
||||||
|
|
||||||
|
# Удаляем старые образы postgres, если они есть
|
||||||
|
echo -e "${YELLOW} Удаление старых образов postgres...${NC}"
|
||||||
|
docker images | grep -E "postgres:(15|16|17|18|latest)" | awk '{print $3}' | xargs -r docker rmi -f 2>/dev/null || true
|
||||||
|
echo -e "${GREEN} ✅ Старые образы postgres удалены${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 2. Поднятие всех сервисов
|
||||||
|
echo -e "${YELLOW}2. Поднятие сервисов через Docker Compose...${NC}"
|
||||||
|
echo " - База данных PostgreSQL 18.0 (порт: $DB_PORT)"
|
||||||
|
echo " - Backend сервер (порт: $PORT)"
|
||||||
|
echo " - Frontend приложение (порт: $WEB_PORT)"
|
||||||
|
docker-compose up -d --build
|
||||||
|
echo -e "${GREEN} ✅ Сервисы запущены${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 3. Ожидание готовности базы данных
|
||||||
|
echo -e "${YELLOW}3. Ожидание готовности базы данных...${NC}"
|
||||||
|
MAX_WAIT=60
|
||||||
|
WAIT_COUNT=0
|
||||||
|
while ! docker-compose exec -T db pg_isready -U "$DB_USER" >/dev/null 2>&1; do
|
||||||
|
if [ $WAIT_COUNT -ge $MAX_WAIT ]; then
|
||||||
|
echo -e "${RED} ❌ База данных не готова за $MAX_WAIT секунд${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -n "."
|
||||||
|
sleep 1
|
||||||
|
WAIT_COUNT=$((WAIT_COUNT + 1))
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN} ✅ База данных готова${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 4. Поиск самого свежего дампа
|
||||||
|
echo -e "${YELLOW}4. Поиск самого свежего дампа...${NC}"
|
||||||
|
DUMP_DIR="database-dumps"
|
||||||
|
|
||||||
|
if [ ! -d "$DUMP_DIR" ]; then
|
||||||
|
echo -e "${YELLOW} ⚠️ Директория дампов не найдена, создаём...${NC}"
|
||||||
|
mkdir -p "$DUMP_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ищем все дампы (сначала .sql.gz, потом .sql)
|
||||||
|
LATEST_DUMP=$(ls -t "$DUMP_DIR"/*.{sql.gz,sql} 2>/dev/null | head -n 1)
|
||||||
|
|
||||||
|
if [ -z "$LATEST_DUMP" ]; then
|
||||||
|
echo -e "${YELLOW} ⚠️ Дампы не найдены${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Создаём дамп с продакшена используя креденшелы из .env
|
||||||
|
echo -e "${YELLOW}5. Создание дампа с продакшена...${NC}"
|
||||||
|
echo -e "${BLUE} 📦 Используются креденшелы из .env${NC}"
|
||||||
|
echo " Используется скрипт dump-db.sh"
|
||||||
|
|
||||||
|
if [ -f "./dump-db.sh" ]; then
|
||||||
|
chmod +x ./dump-db.sh
|
||||||
|
DUMP_NAME="prod_backup_$(date +%Y%m%d_%H%M%S)"
|
||||||
|
|
||||||
|
# Временно останавливаем контейнер db, чтобы dump-db.sh не использовал docker-compose exec
|
||||||
|
# и подключился напрямую к продакшен базе по креденшелам из .env
|
||||||
|
echo -e "${BLUE} ⏸️ Временно останавливаем локальный контейнер db для создания дампа с продакшена...${NC}"
|
||||||
|
docker-compose stop db 2>/dev/null || true
|
||||||
|
|
||||||
|
# Используем dump-db.sh с креденшелами из .env (по умолчанию)
|
||||||
|
# Теперь он подключится напрямую к продакшен базе, а не через docker-compose
|
||||||
|
./dump-db.sh "$DUMP_NAME"
|
||||||
|
|
||||||
|
# Запускаем контейнер db обратно
|
||||||
|
echo -e "${BLUE} ▶️ Запускаем локальный контейнер db обратно...${NC}"
|
||||||
|
docker-compose start db 2>/dev/null || docker-compose up -d db
|
||||||
|
|
||||||
|
# Проверяем, был ли создан дамп
|
||||||
|
CREATED_DUMP=$(ls -t "$DUMP_DIR"/"$DUMP_NAME".sql.gz 2>/dev/null | head -n 1)
|
||||||
|
if [ -n "$CREATED_DUMP" ]; then
|
||||||
|
echo -e "${GREEN} ✅ Дамп с продакшена создан: $(basename "$CREATED_DUMP")${NC}"
|
||||||
|
LATEST_DUMP="$CREATED_DUMP"
|
||||||
|
# Продолжаем с восстановлением ниже
|
||||||
|
else
|
||||||
|
echo -e "${RED} ❌ Не удалось создать дамп с продакшена${NC}"
|
||||||
|
echo -e "${YELLOW} ⚠️ Проверьте креденшелы в .env и доступность базы данных${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED} ❌ Скрипт dump-db.sh не найден${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Если дамп найден или создан, восстанавливаем его
|
||||||
|
if [ -n "$LATEST_DUMP" ]; then
|
||||||
|
LATEST_DUMP_NAME=$(basename "$LATEST_DUMP")
|
||||||
|
echo -e "${GREEN} ✅ Найден дамп: $LATEST_DUMP_NAME${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 6. Восстановление базы данных
|
||||||
|
echo -e "${YELLOW}6. Восстановление базы данных из дампа...${NC}"
|
||||||
|
echo " Файл: $LATEST_DUMP_NAME"
|
||||||
|
echo " Используется скрипт restore-db.sh (восстановление в локальную базу)"
|
||||||
|
|
||||||
|
# Используем restore-db.sh, который автоматически восстанавливает в локальную базу при использовании .env
|
||||||
|
# restore-db.sh автоматически выберет самый свежий дамп, если имя не указано
|
||||||
|
if [ -f "./restore-db.sh" ]; then
|
||||||
|
chmod +x ./restore-db.sh
|
||||||
|
# Автоматически подтверждаем восстановление
|
||||||
|
# restore-db.sh сам выберет самый свежий дамп из database-dumps/
|
||||||
|
echo "yes" | ./restore-db.sh
|
||||||
|
else
|
||||||
|
echo -e "${RED} ❌ Скрипт restore-db.sh не найден${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✅ Инициализация завершена!${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}📋 Статус сервисов:${NC}"
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
35
list-dumps.sh
Executable file
35
list-dumps.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт для просмотра списка доступных дампов
|
||||||
|
|
||||||
|
DUMP_DIR="database-dumps"
|
||||||
|
|
||||||
|
if [ ! -d "$DUMP_DIR" ]; then
|
||||||
|
echo "❌ Директория дампов не найдена: $DUMP_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 Доступные дампы базы данных:"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Показываем дампы с информацией о размере и дате
|
||||||
|
if ls "$DUMP_DIR"/*.sql.gz 2>/dev/null | grep -q .; then
|
||||||
|
ls -lh "$DUMP_DIR"/*.sql.gz 2>/dev/null | awk '{
|
||||||
|
filename = $9
|
||||||
|
gsub(/.*\//, "", filename)
|
||||||
|
printf " %-40s %8s %s %s %s\n", filename, $5, $6, $7, $8
|
||||||
|
}'
|
||||||
|
echo ""
|
||||||
|
echo "Всего дампов: $(ls -1 "$DUMP_DIR"/*.sql.gz 2>/dev/null | wc -l | tr -d ' ')"
|
||||||
|
echo ""
|
||||||
|
echo "Для восстановления используйте:"
|
||||||
|
echo " ./restore-db.sh <имя_дампа.sql.gz> # В .env"
|
||||||
|
echo " ./restore-db.sh --env-file .env.prod <имя_дампа> # В указанный файл"
|
||||||
|
else
|
||||||
|
echo " (нет дампов)"
|
||||||
|
echo ""
|
||||||
|
echo "Для создания дампа используйте:"
|
||||||
|
echo " ./dump-db.sh # Из .env"
|
||||||
|
echo " ./dump-db.sh --env-file .env.prod [имя] # Из указанного файла"
|
||||||
|
fi
|
||||||
|
|
||||||
127
nginx-unified.conf
Normal file
127
nginx-unified.conf
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
|
||||||
|
|
||||||
|
# Proxy API requests to backend (localhost внутри контейнера)
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost: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_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy webhook endpoints to backend
|
||||||
|
location /webhook/ {
|
||||||
|
proxy_pass http://localhost: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_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy daily-report endpoints to backend
|
||||||
|
location /daily-report/ {
|
||||||
|
proxy_pass http://localhost: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_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy admin panel to backend (must be before location /)
|
||||||
|
location ^~ /admin {
|
||||||
|
proxy_pass http://localhost: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_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy project endpoints to backend (must be before location /)
|
||||||
|
location ^~ /project/ {
|
||||||
|
proxy_pass http://localhost: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_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy other API endpoints to backend
|
||||||
|
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|message/post|weekly_goals/setup|project_score_sample_mv/refresh)$ {
|
||||||
|
proxy_pass http://localhost: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_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Service Worker должен быть без кэширования
|
||||||
|
location /sw.js {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
expires 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Manifest тоже без долгого кэширования
|
||||||
|
location /manifest.webmanifest {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
expires 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Раздача загруженных файлов (картинки wishlist) - проксируем через backend
|
||||||
|
# Используем ^~ чтобы этот location имел приоритет над regex locations
|
||||||
|
location ^~ /uploads/ {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle React Router (SPA)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
29
nginx.conf
Normal file
29
nginx.conf
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
|
||||||
|
# Include server configurations
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
|
|
||||||
9
package.json
Normal file
9
package.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "play-life",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Play Life application",
|
||||||
|
"scripts": {
|
||||||
|
"db:dump": "./dump-db.sh",
|
||||||
|
"db:restore": "./restore-db.sh"
|
||||||
|
}
|
||||||
|
}
|
||||||
34
play-life-backend/.gitignore
vendored
Normal file
34
play-life-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Environment variables with secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Go build artifacts
|
||||||
|
main
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
66
play-life-backend/Dockerfile
Normal file
66
play-life-backend/Dockerfile
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Multi-stage build для единого образа frontend + backend
|
||||||
|
|
||||||
|
# Stage 1: Build Frontend
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY play-life-web/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
# Копируем исходники (node_modules исключены через .dockerignore)
|
||||||
|
COPY play-life-web/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Build Backend
|
||||||
|
FROM golang:1.24-alpine AS backend-builder
|
||||||
|
WORKDIR /app/backend
|
||||||
|
# Устанавливаем GOPROXY для более надежной загрузки модулей
|
||||||
|
ENV GOPROXY=https://proxy.golang.org,direct
|
||||||
|
ENV GOSUMDB=sum.golang.org
|
||||||
|
COPY play-life-backend/go.mod play-life-backend/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY play-life-backend/ .
|
||||||
|
RUN go mod tidy
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
|
||||||
|
|
||||||
|
# Stage 3: Final image
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Устанавливаем необходимые пакеты
|
||||||
|
RUN apk --no-cache add \
|
||||||
|
ca-certificates \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
curl \
|
||||||
|
tzdata \
|
||||||
|
chromium \
|
||||||
|
chromium-chromedriver \
|
||||||
|
udev \
|
||||||
|
ttf-freefont \
|
||||||
|
font-noto-emoji
|
||||||
|
|
||||||
|
# Создаем директории
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем собранный frontend
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Копируем собранный backend
|
||||||
|
COPY --from=backend-builder /app/backend/main /app/backend/main
|
||||||
|
COPY play-life-backend/admin.html /app/backend/admin.html
|
||||||
|
|
||||||
|
# Копируем конфигурацию nginx
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY nginx-unified.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Копируем конфигурацию supervisor для запуска backend
|
||||||
|
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
|
# Создаем директории для логов
|
||||||
|
RUN mkdir -p /var/log/supervisor && \
|
||||||
|
mkdir -p /var/log/nginx && \
|
||||||
|
mkdir -p /var/run
|
||||||
|
|
||||||
|
# Открываем порт 80
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Запускаем supervisor, который запустит nginx и backend
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
75
play-life-backend/ENV_SETUP.md
Normal file
75
play-life-backend/ENV_SETUP.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Настройка переменных окружения
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
1. Скопируйте файл `env.example` в `.env`:
|
||||||
|
```bash
|
||||||
|
cp env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Откройте `.env` и заполните реальные значения:
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
# или
|
||||||
|
vim .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **ВАЖНО**: Файл `.env` уже добавлен в `.gitignore` и не будет попадать в git.
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
### Обязательные (для работы приложения)
|
||||||
|
|
||||||
|
- `DB_HOST` - хост базы данных (по умолчанию: localhost)
|
||||||
|
- `DB_PORT` - порт базы данных (по умолчанию: 5432)
|
||||||
|
- `DB_USER` - пользователь БД (по умолчанию: playeng)
|
||||||
|
- `DB_PASSWORD` - пароль БД (по умолчанию: playeng)
|
||||||
|
- `DB_NAME` - имя БД (по умолчанию: playeng)
|
||||||
|
- `PORT` - порт сервера (по умолчанию: 8080)
|
||||||
|
|
||||||
|
### Опциональные (для Telegram интеграции)
|
||||||
|
|
||||||
|
- `WEBHOOK_BASE_URL` - базовый URL для автоматической настройки webhook
|
||||||
|
- Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram"
|
||||||
|
|
||||||
|
## Использование в коде
|
||||||
|
|
||||||
|
Приложение автоматически читает переменные окружения через `os.Getenv()`.
|
||||||
|
|
||||||
|
Для загрузки `.env` файла в локальной разработке можно использовать:
|
||||||
|
|
||||||
|
### Вариант 1: Установить переменные вручную
|
||||||
|
```bash
|
||||||
|
export DB_PASSWORD=your_password
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 2: Использовать библиотеку godotenv (рекомендуется)
|
||||||
|
|
||||||
|
1. Установить библиотеку:
|
||||||
|
```bash
|
||||||
|
go get github.com/joho/godotenv
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Добавить в начало `main()`:
|
||||||
|
```go
|
||||||
|
import "github.com/joho/godotenv"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Загрузить .env файл
|
||||||
|
godotenv.Load()
|
||||||
|
// ... остальной код
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 3: Использовать docker-compose
|
||||||
|
|
||||||
|
В `docker-compose.yml` уже настроена передача переменных окружения из `.env` файла.
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
- ✅ Файл `.env` добавлен в `.gitignore`
|
||||||
|
- ✅ Файл `env.example` содержит только шаблоны без реальных значений
|
||||||
|
- ✅ Никогда не коммитьте `.env` в git
|
||||||
|
- ✅ Используйте разные токены для dev/prod окружений
|
||||||
|
|
||||||
120
play-life-backend/MIGRATION_BASELINE.md
Normal file
120
play-life-backend/MIGRATION_BASELINE.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Инструкция по применению baseline миграции
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
После перехода на `golang-migrate` текущая схема БД была зафиксирована как baseline миграция `000001_baseline.up.sql`. Для существующих баз данных baseline миграция **не должна применяться автоматически** - вместо этого нужно использовать команду `migrate force` для установки текущей версии миграции.
|
||||||
|
|
||||||
|
## Для существующих баз данных
|
||||||
|
|
||||||
|
### Шаг 1: Создание backup
|
||||||
|
|
||||||
|
**ОБЯЗАТЕЛЬНО** создайте backup базы данных перед применением baseline:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Используйте существующий скрипт dump-db.sh
|
||||||
|
./dump-db.sh
|
||||||
|
|
||||||
|
# Или вручную:
|
||||||
|
pg_dump -h $DB_HOST -U $DB_USER -d $DB_NAME > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 2: Установка версии миграции
|
||||||
|
|
||||||
|
Для существующих баз данных нужно установить версию миграции в `1` (baseline), не применяя саму миграцию:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установите переменные окружения
|
||||||
|
export DB_HOST=localhost
|
||||||
|
export DB_PORT=5432
|
||||||
|
export DB_USER=playeng
|
||||||
|
export DB_PASSWORD=playeng
|
||||||
|
export DB_NAME=playeng
|
||||||
|
|
||||||
|
# Установите версию миграции в 1 (baseline)
|
||||||
|
migrate -path ./play-life-backend/migrations \
|
||||||
|
-database "postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME?sslmode=disable" \
|
||||||
|
force 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:** Команда `force 1` устанавливает версию миграции в `1`, но **не выполняет** SQL из baseline миграции. Это правильно, так как схема уже существует.
|
||||||
|
|
||||||
|
### Шаг 3: Проверка
|
||||||
|
|
||||||
|
Проверьте, что версия миграции установлена правильно:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
migrate -path ./play-life-backend/migrations \
|
||||||
|
-database "postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME?sslmode=disable" \
|
||||||
|
version
|
||||||
|
```
|
||||||
|
|
||||||
|
Должно вывести: `1 (dirty)`
|
||||||
|
|
||||||
|
Если выводит `1 (dirty)`, это нормально - это означает, что версия установлена, но миграция не была применена (что и требуется для baseline).
|
||||||
|
|
||||||
|
### Шаг 4: Очистка dirty флага (опционально)
|
||||||
|
|
||||||
|
Если нужно убрать dirty флаг:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
migrate -path ./play-life-backend/migrations \
|
||||||
|
-database "postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME?sslmode=disable" \
|
||||||
|
force 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Для новых баз данных
|
||||||
|
|
||||||
|
Для новых баз данных baseline миграция применится автоматически при первом запуске приложения через функцию `runMigrations()`.
|
||||||
|
|
||||||
|
Или вручную:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
migrate -path ./play-life-backend/migrations \
|
||||||
|
-database "postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME?sslmode=disable" \
|
||||||
|
up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка схемы
|
||||||
|
|
||||||
|
После применения baseline (или установки версии для существующих БД) можно проверить схему:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Экспорт схемы
|
||||||
|
pg_dump -h $DB_HOST -U $DB_USER -d $DB_NAME --schema-only > current_schema.sql
|
||||||
|
|
||||||
|
# Сравнение с baseline (если нужно)
|
||||||
|
diff current_schema.sql play-life-backend/migrations/000001_baseline.up.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Важные замечания
|
||||||
|
|
||||||
|
1. **Никогда не применяйте baseline миграцию на существующих БД** - используйте только `migrate force 1`
|
||||||
|
2. **Всегда создавайте backup** перед любыми операциями с миграциями
|
||||||
|
3. **Проверяйте версию миграции** после установки baseline
|
||||||
|
4. **Новые миграции** будут применяться автоматически при запуске приложения
|
||||||
|
|
||||||
|
## Устранение проблем
|
||||||
|
|
||||||
|
### Ошибка "dirty database version"
|
||||||
|
|
||||||
|
Если база данных находится в состоянии "dirty", исправьте это:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
migrate -path ./play-life-backend/migrations \
|
||||||
|
-database "postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME?sslmode=disable" \
|
||||||
|
force <version>
|
||||||
|
```
|
||||||
|
|
||||||
|
Где `<version>` - текущая версия миграции (обычно 1 для baseline).
|
||||||
|
|
||||||
|
### Ошибка "no change"
|
||||||
|
|
||||||
|
Если при применении миграций вы видите "no change", это нормально - база данных уже на актуальной версии.
|
||||||
|
|
||||||
|
### Проблемы с путями к миграциям
|
||||||
|
|
||||||
|
Убедитесь, что путь к миграциям правильный:
|
||||||
|
- Локально: `./play-life-backend/migrations`
|
||||||
|
- В Docker: `/migrations`
|
||||||
|
|
||||||
|
Приложение автоматически проверяет оба пути.
|
||||||
458
play-life-backend/MIGRATION_RISKS_AND_SOLUTIONS.md
Normal file
458
play-life-backend/MIGRATION_RISKS_AND_SOLUTIONS.md
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
# Анализ рисков миграции на golang-migrate с baseline
|
||||||
|
|
||||||
|
## Критические риски
|
||||||
|
|
||||||
|
### 1. Потеря данных при неправильном применении baseline
|
||||||
|
|
||||||
|
**Риск**: При применении baseline миграции на существующую БД может произойти:
|
||||||
|
- Попытка создать уже существующие таблицы (ошибка)
|
||||||
|
- Потеря данных при DROP/CREATE операциях
|
||||||
|
- Конфликты с существующими данными
|
||||||
|
|
||||||
|
**Вероятность**: Средняя
|
||||||
|
**Влияние**: Критическое
|
||||||
|
|
||||||
|
**Решения**:
|
||||||
|
|
||||||
|
1. **Обязательный backup перед применением**
|
||||||
|
```bash
|
||||||
|
# Создать backup перед миграцией
|
||||||
|
./dump-db.sh --env-file .env baseline-migration-backup
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Использование `migrate force` вместо `migrate up` для существующих БД**
|
||||||
|
```bash
|
||||||
|
# Для существующих БД - установить версию без применения
|
||||||
|
migrate -path ./migrations -database "postgres://..." force 1
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Проверка существования таблиц в baseline миграции**
|
||||||
|
- Использовать `CREATE TABLE IF NOT EXISTS` (но это не идеально для baseline)
|
||||||
|
- Или создать скрипт проверки перед применением
|
||||||
|
|
||||||
|
4. **Тестирование на dev окружении**
|
||||||
|
- Сначала применить на dev БД
|
||||||
|
- Проверить целостность данных
|
||||||
|
- Только потом применять на production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Ошибки в baseline миграции (неполная схема)
|
||||||
|
|
||||||
|
**Риск**: Baseline миграция может не включать:
|
||||||
|
- Некоторые таблицы или колонки
|
||||||
|
- Индексы или constraints
|
||||||
|
- Materialized views
|
||||||
|
- Начальные данные (словарь с id=0)
|
||||||
|
- Sequences с правильными значениями
|
||||||
|
|
||||||
|
**Вероятность**: Высокая
|
||||||
|
**Влияние**: Критическое
|
||||||
|
|
||||||
|
**Решения**:
|
||||||
|
|
||||||
|
1. **Автоматическая проверка полноты схемы**
|
||||||
|
```bash
|
||||||
|
# Создать скрипт для сравнения текущей схемы с baseline
|
||||||
|
# Использовать pg_dump --schema-only для сравнения
|
||||||
|
pg_dump --schema-only -h $DB_HOST -U $DB_USER -d $DB_NAME > current_schema.sql
|
||||||
|
# Сравнить с baseline миграцией
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Пошаговая сборка baseline**
|
||||||
|
- Собрать схему из всех init*DB функций
|
||||||
|
- Добавить все миграции 012-029
|
||||||
|
- Проверить через `pg_dump --schema-only` на актуальной БД
|
||||||
|
|
||||||
|
3. **Тестирование baseline на чистой БД**
|
||||||
|
```bash
|
||||||
|
# Создать тестовую БД
|
||||||
|
createdb test_baseline
|
||||||
|
# Применить baseline
|
||||||
|
migrate -path ./migrations -database "postgres://.../test_baseline" up
|
||||||
|
# Сравнить схему с production
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Валидация через SQL проверки**
|
||||||
|
- Добавить в baseline проверки существования всех таблиц
|
||||||
|
- Использовать `DO $$ BEGIN ... END $$;` блоки для валидации
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Проблемы с sequences и начальными данными
|
||||||
|
|
||||||
|
**Риск**:
|
||||||
|
- Sequences могут быть не синхронизированы
|
||||||
|
- Начальные данные (словарь id=0) могут конфликтовать
|
||||||
|
- Автоинкременты могут начаться с неправильного значения
|
||||||
|
|
||||||
|
**Вероятность**: Средняя
|
||||||
|
**Влияние**: Среднее
|
||||||
|
|
||||||
|
**Решения**:
|
||||||
|
|
||||||
|
1. **Правильная настройка sequences в baseline**
|
||||||
|
```sql
|
||||||
|
-- После создания таблицы и вставки данных
|
||||||
|
SELECT setval('dictionaries_id_seq',
|
||||||
|
(SELECT MAX(id) FROM dictionaries),
|
||||||
|
true);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Использование ON CONFLICT для начальных данных**
|
||||||
|
```sql
|
||||||
|
INSERT INTO dictionaries (id, name)
|
||||||
|
VALUES (0, 'Все слова')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Проверка sequences после baseline**
|
||||||
|
```sql
|
||||||
|
-- Скрипт для проверки всех sequences
|
||||||
|
SELECT schemaname, sequencename, last_value
|
||||||
|
FROM pg_sequences;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Проблемы с materialized views
|
||||||
|
|
||||||
|
**Риск**:
|
||||||
|
- Materialized view может не создаться корректно
|
||||||
|
- Зависимости от таблиц могут быть нарушены
|
||||||
|
- Данные в MV могут быть неактуальными
|
||||||
|
|
||||||
|
**Вероятность**: Средняя
|
||||||
|
**Влияние**: Среднее
|
||||||
|
|
||||||
|
**Решения**:
|
||||||
|
|
||||||
|
1. **Создание MV после всех таблиц**
|
||||||
|
- Убедиться, что все таблицы созданы до создания MV
|
||||||
|
- Использовать `DROP MATERIALIZED VIEW IF EXISTS` перед созданием
|
||||||
|
|
||||||
|
2. **Обновление данных после создания**
|
||||||
|
```sql
|
||||||
|
-- После создания MV
|
||||||
|
REFRESH MATERIALIZED VIEW weekly_report_mv;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Проверка зависимостей**
|
||||||
|
```sql
|
||||||
|
-- Проверить зависимости MV
|
||||||
|
SELECT * FROM pg_depend
|
||||||
|
WHERE objid = 'weekly_report_mv'::regclass;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Конфликты версий миграций
|
||||||
|
|
||||||
|
**Риск**:
|
||||||
|
- Таблица `schema_migrations` может быть в неправильном состоянии
|
||||||
|
- Версия может быть установлена неправильно
|
||||||
|
- Конфликт между старой и новой системой миграций
|
||||||
|
|
||||||
|
**Вероятность**: Средняя
|
||||||
|
**Влияние**: Высокое
|
||||||
|
|
||||||
|
**Решения**:
|
||||||
|
|
||||||
|
1. **Проверка состояния schema_migrations перед применением**
|
||||||
|
```go
|
||||||
|
// Проверить, существует ли таблица schema_migrations
|
||||||
|
// Если да - проверить текущую версию
|
||||||
|
var version uint
|
||||||
|
err := db.QueryRow("SELECT version FROM schema_migrations LIMIT 1").Scan(&version)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Очистка старой таблицы (если была)**
|
||||||
|
```sql
|
||||||
|
-- Если была старая таблица миграций
|
||||||
|
DROP TABLE IF EXISTS old_migrations_table;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Использование `migrate force` только для существующих БД**
|
||||||
|
- Новые БД должны использовать `migrate up`
|
||||||
|
- Существующие БД - `migrate force 1`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Проблемы с окружениями (dev/prod различия)
|
||||||
|
|
||||||
|
**Риск**:
|
||||||
|
- Различия в схемах между dev и prod
|
||||||
|
- Разные версии PostgreSQL
|
||||||
|
- Разные настройки БД
|
||||||
|
|
||||||
|
**Вероятность**: Средняя
|
||||||
|
**Влияние**: Высокое
|
||||||
|
|
||||||
|
**Решения**:
|
||||||
|
|
||||||
|
1. **Проверка версии PostgreSQL**
|
||||||
|
```sql
|
||||||
|
SELECT version();
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Тестирование на всех окружениях**
|
||||||
|
- Dev окружение
|
||||||
|
- Staging (если есть)
|
||||||
|
- Production (после успешного тестирования)
|
||||||
|
|
||||||
|
3. **Документирование различий**
|
||||||
|
- Зафиксировать версию PostgreSQL
|
||||||
|
- Зафиксировать настройки БД
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Проблемы с откатом (rollback)
|
||||||
|
|
||||||
|
**Риск**:
|
||||||
|
- Baseline миграция не может быть откачена
|
||||||
|
- Ошибки при откате последующих миграций
|
||||||
|
- Потеря данных при откате
|
||||||
|
|
||||||
|
**Вероятность**: Низкая
|
||||||
|
**Влияние**: Высокое
|
||||||
|
|
||||||
|
**Решения**:
|
||||||
|
|
||||||
|
1. **Baseline не откатывается (по дизайну)**
|
||||||
|
- Пустой `000001_baseline.down.sql`
|
||||||
|
- Документировать это ограничение
|
||||||
|
|
||||||
|
2. **Правильные down миграции для новых миграций**
|
||||||
|
- Каждая новая миграция должна иметь корректный down файл
|
||||||
|
- Тестировать откат на dev окружении
|
||||||
|
|
||||||
|
3. **Backup перед откатом**
|
||||||
|
- Всегда создавать backup перед откатом
|
||||||
|
- Особенно на production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Проблемы при старте приложения
|
||||||
|
|
||||||
|
**Риск**:
|
||||||
|
- Миграции могут не примениться при старте
|
||||||
|
- Ошибки подключения к БД во время миграций
|
||||||
|
- Таймауты при применении миграций
|
||||||
|
|
||||||
|
**Вероятность**: Средняя
|
||||||
|
**Влияние**: Высокое
|
||||||
|
|
||||||
|
**Решения**:
|
||||||
|
|
||||||
|
1. **Обработка ошибок миграций**
|
||||||
|
```go
|
||||||
|
m, err := migrate.NewWithDatabaseInstance(
|
||||||
|
"file://migrations",
|
||||||
|
"postgres", driver)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to initialize migrations:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.Up(); err != nil {
|
||||||
|
if err != migrate.ErrNoChange {
|
||||||
|
log.Fatal("Failed to apply migrations:", err)
|
||||||
|
}
|
||||||
|
log.Println("Database is up to date")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Retry логика для подключения к БД**
|
||||||
|
- Уже есть в коде (10 попыток)
|
||||||
|
- Применить перед миграциями
|
||||||
|
|
||||||
|
3. **Таймауты для миграций**
|
||||||
|
```go
|
||||||
|
// Установить таймаут для миграций
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Логирование процесса миграций**
|
||||||
|
- Логировать каждую применяемую миграцию
|
||||||
|
- Логировать ошибки с деталями
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Проблемы с Docker и путями к миграциям
|
||||||
|
|
||||||
|
**Риск**:
|
||||||
|
- Миграции могут не найтись в контейнере
|
||||||
|
- Неправильные пути к файлам миграций
|
||||||
|
- Проблемы с правами доступа
|
||||||
|
|
||||||
|
**Вероятность**: Низкая
|
||||||
|
**Влияние**: Среднее
|
||||||
|
|
||||||
|
**Решения**:
|
||||||
|
|
||||||
|
1. **Проверка путей в Dockerfile**
|
||||||
|
```dockerfile
|
||||||
|
# Убедиться, что миграции копируются
|
||||||
|
COPY play-life-backend/migrations /migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Использование абсолютных путей**
|
||||||
|
```go
|
||||||
|
migrationsPath := "/migrations"
|
||||||
|
if _, err := os.Stat(migrationsPath); os.IsNotExist(err) {
|
||||||
|
// Fallback для локальной разработки
|
||||||
|
migrationsPath = "play-life-backend/migrations"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Проверка доступности миграций при старте**
|
||||||
|
```go
|
||||||
|
// Проверить, что папка миграций существует
|
||||||
|
if _, err := os.Stat(migrationsPath); os.IsNotExist(err) {
|
||||||
|
log.Fatal("Migrations directory not found:", migrationsPath)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. Проблемы с параллельным доступом
|
||||||
|
|
||||||
|
**Риск**:
|
||||||
|
- Несколько инстансов приложения могут пытаться применить миграции одновременно
|
||||||
|
- Конфликты при применении миграций
|
||||||
|
|
||||||
|
**Вероятность**: Низкая
|
||||||
|
**Влияние**: Высокое
|
||||||
|
|
||||||
|
**Решения**:
|
||||||
|
|
||||||
|
1. **Блокировки на уровне БД**
|
||||||
|
- golang-migrate использует транзакции
|
||||||
|
- PostgreSQL блокирует таблицу schema_migrations
|
||||||
|
|
||||||
|
2. **Применение миграций только в одном инстансе**
|
||||||
|
- Использовать флаг `--migrate` для запуска миграций
|
||||||
|
- Или применять миграции отдельным процессом
|
||||||
|
|
||||||
|
3. **Проверка версии перед применением**
|
||||||
|
```go
|
||||||
|
version, dirty, err := m.Version()
|
||||||
|
if dirty {
|
||||||
|
log.Fatal("Database is in dirty state, manual intervention required")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## План митигации рисков
|
||||||
|
|
||||||
|
### Этап 1: Подготовка (до применения baseline)
|
||||||
|
|
||||||
|
1. ✅ Создать backup всех БД (dev, staging, prod)
|
||||||
|
2. ✅ Собрать полную схему через `pg_dump --schema-only`
|
||||||
|
3. ✅ Создать baseline миграцию на основе схемы
|
||||||
|
4. ✅ Протестировать baseline на чистой БД
|
||||||
|
5. ✅ Сравнить схему после baseline с текущей схемой
|
||||||
|
|
||||||
|
### Этап 2: Тестирование (на dev окружении)
|
||||||
|
|
||||||
|
1. ✅ Применить baseline через `migrate force 1`
|
||||||
|
2. ✅ Проверить целостность данных
|
||||||
|
3. ✅ Проверить работу приложения
|
||||||
|
4. ✅ Проверить sequences и начальные данные
|
||||||
|
5. ✅ Проверить materialized views
|
||||||
|
|
||||||
|
### Этап 3: Применение (на production)
|
||||||
|
|
||||||
|
1. ✅ Создать backup production БД
|
||||||
|
2. ✅ Применить baseline через `migrate force 1`
|
||||||
|
3. ✅ Проверить работу приложения
|
||||||
|
4. ✅ Мониторинг в течение первых часов
|
||||||
|
|
||||||
|
### Этап 4: Мониторинг (после применения)
|
||||||
|
|
||||||
|
1. ✅ Проверить логи приложения
|
||||||
|
2. ✅ Проверить ошибки БД
|
||||||
|
3. ✅ Проверить производительность
|
||||||
|
4. ✅ Собрать обратную связь от пользователей
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Чеклист перед применением baseline
|
||||||
|
|
||||||
|
- [ ] Backup всех БД создан и проверен
|
||||||
|
- [ ] Baseline миграция протестирована на чистой БД
|
||||||
|
- [ ] Схема после baseline совпадает с текущей схемой
|
||||||
|
- [ ] Тестирование на dev окружении успешно
|
||||||
|
- [ ] Инструкции по применению baseline готовы
|
||||||
|
- [ ] Команда проинформирована о миграции
|
||||||
|
- [ ] Окно для миграции запланировано (для production)
|
||||||
|
- [ ] План отката подготовлен (если что-то пойдет не так)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Скрипты для проверки
|
||||||
|
|
||||||
|
### Скрипт проверки схемы
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# check_schema.sh - Проверка полноты baseline миграции
|
||||||
|
|
||||||
|
DB_HOST=${DB_HOST:-localhost}
|
||||||
|
DB_PORT=${DB_PORT:-5432}
|
||||||
|
DB_USER=${DB_USER:-playeng}
|
||||||
|
DB_PASSWORD=${DB_PASSWORD:-playeng}
|
||||||
|
DB_NAME=${DB_NAME:-playeng}
|
||||||
|
|
||||||
|
echo "Проверка схемы БД..."
|
||||||
|
|
||||||
|
# Получить список всех таблиц
|
||||||
|
PGPASSWORD="$DB_PASSWORD" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "
|
||||||
|
SELECT tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE schemaname = 'public'
|
||||||
|
ORDER BY tablename;
|
||||||
|
" > current_tables.txt
|
||||||
|
|
||||||
|
echo "Таблицы в БД сохранены в current_tables.txt"
|
||||||
|
echo "Сравните с таблицами в baseline миграции"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Скрипт применения baseline
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# apply_baseline.sh - Безопасное применение baseline
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DB_HOST=${DB_HOST:-localhost}
|
||||||
|
DB_PORT=${DB_PORT:-5432}
|
||||||
|
DB_USER=${DB_USER:-playeng}
|
||||||
|
DB_PASSWORD=${DB_PASSWORD:-playeng}
|
||||||
|
DB_NAME=${DB_NAME:-playeng}
|
||||||
|
|
||||||
|
DATABASE_URL="postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME?sslmode=disable"
|
||||||
|
|
||||||
|
echo "⚠️ ВНИМАНИЕ: Это применит baseline миграцию!"
|
||||||
|
echo "База данных: $DB_NAME"
|
||||||
|
echo "Хост: $DB_HOST:$DB_PORT"
|
||||||
|
read -p "Вы уверены? (yes/no): " confirm
|
||||||
|
|
||||||
|
if [ "$confirm" != "yes" ]; then
|
||||||
|
echo "Отменено"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Создать backup
|
||||||
|
echo "Создание backup..."
|
||||||
|
./dump-db.sh --env-file .env baseline-backup-$(date +%Y%m%d_%H%M%S)
|
||||||
|
|
||||||
|
# Применить baseline
|
||||||
|
echo "Применение baseline..."
|
||||||
|
migrate -path ./play-life-backend/migrations -database "$DATABASE_URL" force 1
|
||||||
|
|
||||||
|
echo "✅ Baseline применен успешно"
|
||||||
|
echo "Проверьте работу приложения"
|
||||||
|
```
|
||||||
394
play-life-backend/admin.html
Normal file
394
play-life-backend/admin.html
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Play Life Backend - Admin Panel</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 2.5em;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.3em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 150px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: monospace;
|
||||||
|
resize: vertical;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card button {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 5px;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result h3 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result pre {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #f8f8f2;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.success {
|
||||||
|
border-left-color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.error {
|
||||||
|
border-left-color: #f44336;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.loading {
|
||||||
|
border-left-color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.success {
|
||||||
|
background: #4caf50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.error {
|
||||||
|
background: #f44336;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.loading {
|
||||||
|
background: #ff9800;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 50px auto;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error h2 {
|
||||||
|
color: #f44336;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error p {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error a {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error a:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="authErrorContainer" style="display: none;">
|
||||||
|
<div class="auth-error">
|
||||||
|
<h2>⚠️ Требуется авторизация</h2>
|
||||||
|
<p id="authErrorMessage">Для доступа к админ-панели необходимо войти в систему как администратор.</p>
|
||||||
|
<a href="/" target="_self">Перейти на главную страницу</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container" id="mainContainer">
|
||||||
|
<h1>🎯 Play Life Backend - Admin Panel</h1>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<!-- Weekly Goals Setup Card -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
🎯 Weekly Goals Setup
|
||||||
|
<span class="status" id="goalsStatus" style="display: none;"></span>
|
||||||
|
</h2>
|
||||||
|
<p style="margin-bottom: 15px; color: #666;">
|
||||||
|
Нажмите кнопку для установки целей на текущую неделю на основе медианы за последние 3 месяца (с отправкой в чат). Обычно срабатывает автоматически в начале недели.
|
||||||
|
</p>
|
||||||
|
<button onclick="setupWeeklyGoals()">Обновить цели</button>
|
||||||
|
<div id="goalsResult"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project score sample MV Card -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>
|
||||||
|
📊 project_score_sample_mv
|
||||||
|
<span class="status" id="mvStatus" style="display: none;"></span>
|
||||||
|
</h2>
|
||||||
|
<p style="margin-bottom: 15px; color: #666;">
|
||||||
|
Обновить материализованное представление и показать данные текущего пользователя (по одному представителю на вариант баллов проекта).
|
||||||
|
</p>
|
||||||
|
<button onclick="refreshProjectScoreSampleMv()">Обновить project_score_sample_mv</button>
|
||||||
|
<div id="mvResult"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Получаем токен из localStorage
|
||||||
|
function getAuthToken() {
|
||||||
|
return localStorage.getItem('access_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем авторизацию при загрузке страницы
|
||||||
|
function checkAuth() {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) {
|
||||||
|
showAuthError('Токен авторизации не найден. Пожалуйста, войдите в систему.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем сообщение об ошибке авторизации
|
||||||
|
function showAuthError(message) {
|
||||||
|
document.getElementById('authErrorContainer').style.display = 'block';
|
||||||
|
document.getElementById('mainContainer').style.display = 'none';
|
||||||
|
document.getElementById('authErrorMessage').textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрабатываем ошибки авторизации
|
||||||
|
function handleAuthError(response) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
showAuthError('Сессия истекла. Пожалуйста, войдите в систему снова.');
|
||||||
|
return true;
|
||||||
|
} else if (response.status === 403) {
|
||||||
|
showAuthError('У вас нет прав доступа к админ-панели. Требуются права администратора.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем заголовки с авторизацией
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiUrl() {
|
||||||
|
// Автоматически определяем URL текущего хоста
|
||||||
|
// Админка обслуживается тем же бекендом, поэтому используем текущий origin
|
||||||
|
return window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем авторизацию при загрузке страницы
|
||||||
|
if (!checkAuth()) {
|
||||||
|
// Страница уже скрыта в checkAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(elementId, status, text) {
|
||||||
|
const statusEl = document.getElementById(elementId);
|
||||||
|
statusEl.textContent = text;
|
||||||
|
statusEl.className = `status ${status}`;
|
||||||
|
statusEl.style.display = 'inline-block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideStatus(elementId) {
|
||||||
|
document.getElementById(elementId).style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResult(elementId, data, isError = false, isLoading = false) {
|
||||||
|
const resultEl = document.getElementById(elementId);
|
||||||
|
resultEl.innerHTML = '';
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
resultEl.innerHTML = '<div class="result loading"><h3>⏳ Загрузка...</h3></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `result ${isError ? 'error' : 'success'}`;
|
||||||
|
|
||||||
|
const h3 = document.createElement('h3');
|
||||||
|
h3.textContent = isError ? '❌ Ошибка' : '✅ Успешно';
|
||||||
|
div.appendChild(h3);
|
||||||
|
|
||||||
|
const pre = document.createElement('pre');
|
||||||
|
pre.textContent = JSON.stringify(data, null, 2);
|
||||||
|
div.appendChild(pre);
|
||||||
|
|
||||||
|
resultEl.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupWeeklyGoals() {
|
||||||
|
showStatus('goalsStatus', 'loading', 'Обновление...');
|
||||||
|
showResult('goalsResult', null, false, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getApiUrl()}/weekly_goals/setup`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (handleAuthError(response)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showStatus('goalsStatus', 'success', 'Успешно');
|
||||||
|
showResult('goalsResult', data, false);
|
||||||
|
} else {
|
||||||
|
showStatus('goalsStatus', 'error', 'Ошибка');
|
||||||
|
showResult('goalsResult', data, true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showStatus('goalsStatus', 'error', 'Ошибка');
|
||||||
|
showResult('goalsResult', { error: error.message }, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshProjectScoreSampleMv() {
|
||||||
|
showStatus('mvStatus', 'loading', 'Обновление...');
|
||||||
|
showResult('mvResult', null, false, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getApiUrl()}/project_score_sample_mv/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (handleAuthError(response)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showStatus('mvStatus', 'success', 'Успешно');
|
||||||
|
showResult('mvResult', data, false);
|
||||||
|
} else {
|
||||||
|
showStatus('mvStatus', 'error', 'Ошибка');
|
||||||
|
showResult('mvResult', data, true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showStatus('mvStatus', 'error', 'Ошибка');
|
||||||
|
showResult('mvResult', { error: error.message }, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
168
play-life-backend/apply_baseline.sh
Executable file
168
play-life-backend/apply_baseline.sh
Executable file
@@ -0,0 +1,168 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Безопасный скрипт для применения baseline миграции к существующим БД
|
||||||
|
# Включает создание backup, проверки и применение baseline
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Цвета для вывода
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Получаем переменные окружения
|
||||||
|
DB_HOST=${DB_HOST:-localhost}
|
||||||
|
DB_PORT=${DB_PORT:-5432}
|
||||||
|
DB_USER=${DB_USER:-playeng}
|
||||||
|
DB_PASSWORD=${DB_PASSWORD:-playeng}
|
||||||
|
DB_NAME=${DB_NAME:-playeng}
|
||||||
|
|
||||||
|
MIGRATIONS_PATH="play-life-backend/migrations"
|
||||||
|
BACKUP_DIR="../database-dumps"
|
||||||
|
|
||||||
|
echo "=== Применение baseline миграции ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверяем наличие необходимых инструментов
|
||||||
|
if ! command -v migrate &> /dev/null; then
|
||||||
|
echo -e "${RED}Ошибка: migrate не найден. Установите golang-migrate:${NC}"
|
||||||
|
echo " brew install golang-migrate"
|
||||||
|
echo " или"
|
||||||
|
echo " go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v pg_dump &> /dev/null; then
|
||||||
|
echo -e "${RED}Ошибка: pg_dump не найден. Установите PostgreSQL client tools.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Проверяем наличие директории миграций
|
||||||
|
if [ ! -d "$MIGRATIONS_PATH" ]; then
|
||||||
|
echo -e "${RED}Ошибка: Директория миграций не найдена: $MIGRATIONS_PATH${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Проверяем наличие baseline миграции
|
||||||
|
if [ ! -f "$MIGRATIONS_PATH/000001_baseline.up.sql" ]; then
|
||||||
|
echo -e "${RED}Ошибка: Baseline миграция не найдена: $MIGRATIONS_PATH/000001_baseline.up.sql${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Параметры подключения:"
|
||||||
|
echo " Host: $DB_HOST"
|
||||||
|
echo " Port: $DB_PORT"
|
||||||
|
echo " User: $DB_USER"
|
||||||
|
echo " Database: $DB_NAME"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверяем подключение к БД
|
||||||
|
echo "1. Проверка подключения к БД..."
|
||||||
|
PGPASSWORD=$DB_PASSWORD psql \
|
||||||
|
-h $DB_HOST \
|
||||||
|
-p $DB_PORT \
|
||||||
|
-U $DB_USER \
|
||||||
|
-d $DB_NAME \
|
||||||
|
-c "SELECT 1;" > /dev/null 2>&1
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Ошибка: Не удалось подключиться к БД${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Подключение успешно${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверяем текущую версию миграции
|
||||||
|
echo "2. Проверка текущей версии миграции..."
|
||||||
|
DATABASE_URL="postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$DB_NAME?sslmode=disable"
|
||||||
|
|
||||||
|
CURRENT_VERSION=$(migrate -path "$MIGRATIONS_PATH" -database "$DATABASE_URL" version 2>&1 || echo "none")
|
||||||
|
|
||||||
|
if echo "$CURRENT_VERSION" | grep -q "dirty"; then
|
||||||
|
echo -e "${YELLOW}⚠ База данных находится в состоянии 'dirty'${NC}"
|
||||||
|
echo " Это нормально для baseline - будет исправлено"
|
||||||
|
elif echo "$CURRENT_VERSION" | grep -q "^[0-9]"; then
|
||||||
|
VERSION_NUM=$(echo "$CURRENT_VERSION" | grep -oE "^[0-9]+" || echo "0")
|
||||||
|
if [ "$VERSION_NUM" -ge 1 ]; then
|
||||||
|
echo -e "${GREEN}✓ Версия миграции уже установлена: $VERSION_NUM${NC}"
|
||||||
|
echo " Baseline уже применен, дальнейшие действия не требуются"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Текущая версия: $CURRENT_VERSION"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Создаем backup
|
||||||
|
echo "3. Создание backup БД..."
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
BACKUP_FILE="$BACKUP_DIR/baseline_backup_$(date +%Y%m%d_%H%M%S).sql.gz"
|
||||||
|
|
||||||
|
PGPASSWORD=$DB_PASSWORD pg_dump \
|
||||||
|
-h $DB_HOST \
|
||||||
|
-p $DB_PORT \
|
||||||
|
-U $DB_USER \
|
||||||
|
-d $DB_NAME \
|
||||||
|
| gzip > "$BACKUP_FILE"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Ошибка: Не удалось создать backup${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
|
||||||
|
echo -e "${GREEN}✓ Backup создан: $BACKUP_FILE (размер: $BACKUP_SIZE)${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Подтверждение
|
||||||
|
echo "4. Подтверждение применения baseline..."
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}ВНИМАНИЕ:${NC}"
|
||||||
|
echo " Будет установлена версия миграции в 1 (baseline)"
|
||||||
|
echo " Сама миграция НЕ будет применена (схема уже существует)"
|
||||||
|
echo " Backup сохранен в: $BACKUP_FILE"
|
||||||
|
echo ""
|
||||||
|
read -p "Продолжить? (yes/no): " CONFIRM
|
||||||
|
|
||||||
|
if [ "$CONFIRM" != "yes" ]; then
|
||||||
|
echo "Отменено пользователем"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Применяем baseline (force 1)
|
||||||
|
echo ""
|
||||||
|
echo "5. Установка версии миграции в 1 (baseline)..."
|
||||||
|
migrate -path "$MIGRATIONS_PATH" \
|
||||||
|
-database "$DATABASE_URL" \
|
||||||
|
force 1
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Ошибка: Не удалось установить версию миграции${NC}"
|
||||||
|
echo " Backup доступен в: $BACKUP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Версия миграции установлена${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверяем результат
|
||||||
|
echo "6. Проверка результата..."
|
||||||
|
FINAL_VERSION=$(migrate -path "$MIGRATIONS_PATH" -database "$DATABASE_URL" version 2>&1)
|
||||||
|
echo " Версия миграции: $FINAL_VERSION"
|
||||||
|
|
||||||
|
if echo "$FINAL_VERSION" | grep -qE "^1"; then
|
||||||
|
echo -e "${GREEN}✓ Baseline успешно применен!${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠ Версия миграции: $FINAL_VERSION${NC}"
|
||||||
|
echo " Это может быть нормально, если база в состоянии 'dirty'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Готово ==="
|
||||||
|
echo ""
|
||||||
|
echo "Backup сохранен в: $BACKUP_FILE"
|
||||||
|
echo "Версия миграции установлена в: 1 (baseline)"
|
||||||
|
echo ""
|
||||||
|
echo "Теперь приложение будет автоматически применять новые миграции при запуске."
|
||||||
41
play-life-backend/docker-compose.yml
Normal file
41
play-life-backend/docker-compose.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER:-playeng}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-playeng}
|
||||||
|
POSTGRES_DB: ${DB_NAME:-playeng}
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
env_file:
|
||||||
|
- ../.env
|
||||||
|
- .env # Локальный .env имеет приоритет
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8080}:8080"
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_USER: ${DB_USER:-playeng}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD:-playeng}
|
||||||
|
DB_NAME: ${DB_NAME:-playeng}
|
||||||
|
PORT: ${PORT:-8080}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./migrations:/migrations
|
||||||
|
env_file:
|
||||||
|
- ../.env
|
||||||
|
- .env # Локальный .env имеет приоритет
|
||||||
|
|
||||||
27
play-life-backend/go.mod
Normal file
27
play-life-backend/go.mod
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
module play-eng-backend
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/chromedp/chromedp v0.14.2
|
||||||
|
github.com/disintegration/imaging v1.6.2
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||||
|
github.com/gorilla/mux v1.8.1
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/lib/pq v1.10.9
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
|
golang.org/x/crypto v0.45.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect
|
||||||
|
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
|
||||||
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
|
github.com/gobwas/ws v1.4.0 // indirect
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
)
|
||||||
98
play-life-backend/go.sum
Normal file
98
play-life-backend/go.sum
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||||
|
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
|
||||||
|
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
|
||||||
|
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
|
||||||
|
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
|
||||||
|
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
|
||||||
|
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
|
||||||
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||||
|
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||||
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
|
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||||
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
|
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||||
|
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
|
||||||
|
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
|
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
||||||
|
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
|
||||||
|
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
|
||||||
|
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
|
||||||
|
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
|
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
|
||||||
|
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
|
||||||
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||||
|
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||||
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
|
||||||
|
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
|
||||||
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||||
|
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||||
|
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||||
|
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||||
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
|
||||||
|
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||||
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
|
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
|
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
|
||||||
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
16617
play-life-backend/main.go
Normal file
16617
play-life-backend/main.go
Normal file
File diff suppressed because it is too large
Load Diff
3
play-life-backend/migrations/000001_baseline.down.sql
Normal file
3
play-life-backend/migrations/000001_baseline.down.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Baseline migration cannot be rolled back
|
||||||
|
-- This is the initial state of the database schema
|
||||||
|
-- If you need to revert, you must manually drop all tables and recreate from scratch
|
||||||
497
play-life-backend/migrations/000001_baseline.up.sql
Normal file
497
play-life-backend/migrations/000001_baseline.up.sql
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
-- Baseline Migration: Complete database schema
|
||||||
|
-- This migration represents the current state of the database schema
|
||||||
|
-- For existing databases, use: migrate force 1 (do not run this migration)
|
||||||
|
-- For new databases, this will create the complete schema
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Core Tables (no dependencies)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Users table (base for multi-tenancy)
|
||||||
|
CREATE TABLE users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
last_login_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_users_email ON users(email);
|
||||||
|
|
||||||
|
-- Dictionaries table
|
||||||
|
CREATE TABLE dictionaries (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_dictionaries_user_id ON dictionaries(user_id);
|
||||||
|
|
||||||
|
-- Insert default dictionary with id = 0
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Set sequence to -1 so next value will be 0
|
||||||
|
PERFORM setval('dictionaries_id_seq', -1, false);
|
||||||
|
|
||||||
|
-- Insert the default dictionary with id = 0
|
||||||
|
INSERT INTO dictionaries (id, name)
|
||||||
|
VALUES (0, 'Все слова')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Set the sequence to start from 1 (so next auto-increment will be 1)
|
||||||
|
PERFORM setval('dictionaries_id_seq', 1, false);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN others THEN
|
||||||
|
-- If sequence doesn't exist or other error, try without sequence manipulation
|
||||||
|
INSERT INTO dictionaries (id, name)
|
||||||
|
VALUES (0, 'Все слова')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Projects table
|
||||||
|
CREATE TABLE projects (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
priority SMALLINT,
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_projects_deleted ON projects(deleted);
|
||||||
|
CREATE INDEX idx_projects_user_id ON projects(user_id);
|
||||||
|
|
||||||
|
-- Entries table
|
||||||
|
CREATE TABLE entries (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
created_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_entries_user_id ON entries(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Dependent Tables
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Words table (depends on dictionaries, users)
|
||||||
|
CREATE TABLE words (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
translation TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
dictionary_id INTEGER NOT NULL DEFAULT 0 REFERENCES dictionaries(id),
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_words_dictionary_id ON words(dictionary_id);
|
||||||
|
CREATE INDEX idx_words_user_id ON words(user_id);
|
||||||
|
|
||||||
|
-- Progress table (depends on words, users)
|
||||||
|
CREATE TABLE progress (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE,
|
||||||
|
success INTEGER DEFAULT 0,
|
||||||
|
failure INTEGER DEFAULT 0,
|
||||||
|
last_success_at TIMESTAMP,
|
||||||
|
last_failure_at TIMESTAMP,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT progress_word_user_unique UNIQUE (word_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_progress_user_id ON progress(user_id);
|
||||||
|
CREATE UNIQUE INDEX idx_progress_word_user_unique ON progress(word_id, user_id);
|
||||||
|
|
||||||
|
-- Configs table (depends on users)
|
||||||
|
CREATE TABLE configs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
words_count INTEGER NOT NULL,
|
||||||
|
max_cards INTEGER,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_configs_user_id ON configs(user_id);
|
||||||
|
|
||||||
|
-- Config dictionaries table (depends on configs, dictionaries)
|
||||||
|
CREATE TABLE config_dictionaries (
|
||||||
|
config_id INTEGER NOT NULL REFERENCES configs(id) ON DELETE CASCADE,
|
||||||
|
dictionary_id INTEGER NOT NULL REFERENCES dictionaries(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (config_id, dictionary_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_config_dictionaries_config_id ON config_dictionaries(config_id);
|
||||||
|
CREATE INDEX idx_config_dictionaries_dictionary_id ON config_dictionaries(dictionary_id);
|
||||||
|
|
||||||
|
-- Nodes table (depends on projects, entries, users)
|
||||||
|
CREATE TABLE nodes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
score NUMERIC(8,4),
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_nodes_project_id ON nodes(project_id);
|
||||||
|
CREATE INDEX idx_nodes_entry_id ON nodes(entry_id);
|
||||||
|
CREATE INDEX idx_nodes_user_id ON nodes(user_id);
|
||||||
|
|
||||||
|
-- Weekly goals table (depends on projects, users)
|
||||||
|
CREATE TABLE weekly_goals (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
goal_year INTEGER NOT NULL,
|
||||||
|
goal_week INTEGER NOT NULL,
|
||||||
|
min_goal_score NUMERIC(10,4) NOT NULL DEFAULT 0,
|
||||||
|
max_goal_score NUMERIC(10,4),
|
||||||
|
max_score NUMERIC(10,4),
|
||||||
|
priority SMALLINT,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT weekly_goals_project_id_goal_year_goal_week_key UNIQUE (project_id, goal_year, goal_week)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_weekly_goals_project_id ON weekly_goals(project_id);
|
||||||
|
CREATE INDEX idx_weekly_goals_user_id ON weekly_goals(user_id);
|
||||||
|
|
||||||
|
-- Tasks table (depends on users)
|
||||||
|
CREATE TABLE tasks (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
completed INTEGER DEFAULT 0,
|
||||||
|
last_completed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
reward_message TEXT,
|
||||||
|
progression_base NUMERIC(10,4),
|
||||||
|
deleted BOOLEAN DEFAULT FALSE,
|
||||||
|
repetition_period INTERVAL,
|
||||||
|
next_show_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
repetition_date TEXT,
|
||||||
|
config_id INTEGER REFERENCES configs(id) ON DELETE SET NULL,
|
||||||
|
wishlist_id INTEGER,
|
||||||
|
reward_policy VARCHAR(20) DEFAULT 'personal'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tasks_user_id ON tasks(user_id);
|
||||||
|
CREATE INDEX idx_tasks_parent_task_id ON tasks(parent_task_id);
|
||||||
|
CREATE INDEX idx_tasks_deleted ON tasks(deleted);
|
||||||
|
CREATE INDEX idx_tasks_last_completed_at ON tasks(last_completed_at);
|
||||||
|
CREATE INDEX idx_tasks_config_id ON tasks(config_id);
|
||||||
|
CREATE UNIQUE INDEX idx_tasks_config_id_unique ON tasks(config_id) WHERE config_id IS NOT NULL AND deleted = FALSE;
|
||||||
|
CREATE INDEX idx_tasks_wishlist_id ON tasks(wishlist_id);
|
||||||
|
CREATE UNIQUE INDEX idx_tasks_wishlist_id_unique ON tasks(wishlist_id) WHERE wishlist_id IS NOT NULL AND deleted = FALSE;
|
||||||
|
CREATE INDEX idx_tasks_id_user_deleted ON tasks(id, user_id, deleted) WHERE deleted = FALSE;
|
||||||
|
CREATE INDEX idx_tasks_parent_deleted_covering ON tasks(parent_task_id, deleted, id)
|
||||||
|
INCLUDE (name, completed, last_completed_at, reward_message, progression_base)
|
||||||
|
WHERE deleted = FALSE;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN tasks.config_id IS 'Link to test config. NULL if task is not a test.';
|
||||||
|
COMMENT ON COLUMN tasks.reward_policy IS 'For wishlist tasks: personal = only if user completes, shared = anyone completes';
|
||||||
|
|
||||||
|
-- Reward configs table (depends on tasks, projects)
|
||||||
|
CREATE TABLE reward_configs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
value NUMERIC(10,4) NOT NULL,
|
||||||
|
use_progression BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_reward_configs_task_id ON reward_configs(task_id);
|
||||||
|
CREATE INDEX idx_reward_configs_project_id ON reward_configs(project_id);
|
||||||
|
CREATE UNIQUE INDEX idx_reward_configs_task_position ON reward_configs(task_id, position);
|
||||||
|
|
||||||
|
-- Telegram integrations table (depends on users)
|
||||||
|
CREATE TABLE telegram_integrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
chat_id VARCHAR(255),
|
||||||
|
telegram_user_id BIGINT,
|
||||||
|
start_token VARCHAR(255),
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_telegram_integrations_user_id_unique ON telegram_integrations(user_id) WHERE user_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_telegram_integrations_user_id ON telegram_integrations(user_id);
|
||||||
|
CREATE UNIQUE INDEX idx_telegram_integrations_start_token ON telegram_integrations(start_token) WHERE start_token IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX idx_telegram_integrations_telegram_user_id ON telegram_integrations(telegram_user_id) WHERE telegram_user_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_telegram_integrations_chat_id ON telegram_integrations(chat_id) WHERE chat_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Todoist integrations table (depends on users)
|
||||||
|
CREATE TABLE todoist_integrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
todoist_user_id BIGINT,
|
||||||
|
todoist_email VARCHAR(255),
|
||||||
|
access_token TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT todoist_integrations_user_id_unique UNIQUE (user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_todoist_integrations_user_id ON todoist_integrations(user_id);
|
||||||
|
CREATE UNIQUE INDEX idx_todoist_integrations_todoist_user_id ON todoist_integrations(todoist_user_id) WHERE todoist_user_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX idx_todoist_integrations_todoist_email ON todoist_integrations(todoist_email) WHERE todoist_email IS NOT NULL;
|
||||||
|
|
||||||
|
-- Wishlist boards table (depends on users)
|
||||||
|
CREATE TABLE wishlist_boards (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
invite_token VARCHAR(64) UNIQUE,
|
||||||
|
invite_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wishlist_boards_owner_id ON wishlist_boards(owner_id);
|
||||||
|
CREATE INDEX idx_wishlist_boards_invite_token ON wishlist_boards(invite_token) WHERE invite_token IS NOT NULL;
|
||||||
|
CREATE INDEX idx_wishlist_boards_owner_deleted ON wishlist_boards(owner_id, deleted);
|
||||||
|
|
||||||
|
-- Wishlist board members table (depends on wishlist_boards, users)
|
||||||
|
CREATE TABLE wishlist_board_members (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
board_id INTEGER NOT NULL REFERENCES wishlist_boards(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT unique_board_member UNIQUE (board_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_board_members_board_id ON wishlist_board_members(board_id);
|
||||||
|
CREATE INDEX idx_board_members_user_id ON wishlist_board_members(user_id);
|
||||||
|
|
||||||
|
-- Wishlist items table (depends on users, wishlist_boards)
|
||||||
|
CREATE TABLE wishlist_items (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
price NUMERIC(10,2),
|
||||||
|
image_path VARCHAR(500),
|
||||||
|
link TEXT,
|
||||||
|
completed BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
deleted BOOLEAN DEFAULT FALSE,
|
||||||
|
board_id INTEGER REFERENCES wishlist_boards(id) ON DELETE CASCADE,
|
||||||
|
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wishlist_items_user_id ON wishlist_items(user_id);
|
||||||
|
CREATE INDEX idx_wishlist_items_user_deleted ON wishlist_items(user_id, deleted);
|
||||||
|
CREATE INDEX idx_wishlist_items_user_completed ON wishlist_items(user_id, completed, deleted);
|
||||||
|
CREATE INDEX idx_wishlist_items_board_id ON wishlist_items(board_id);
|
||||||
|
CREATE INDEX idx_wishlist_items_author_id ON wishlist_items(author_id);
|
||||||
|
CREATE INDEX idx_wishlist_items_id_deleted_covering ON wishlist_items(id, deleted)
|
||||||
|
INCLUDE (name)
|
||||||
|
WHERE deleted = FALSE;
|
||||||
|
|
||||||
|
-- Add foreign key for tasks.wishlist_id after wishlist_items is created
|
||||||
|
ALTER TABLE tasks ADD CONSTRAINT tasks_wishlist_id_fkey
|
||||||
|
FOREIGN KEY (wishlist_id) REFERENCES wishlist_items(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
COMMENT ON TABLE wishlist_items IS 'Wishlist items for users';
|
||||||
|
COMMENT ON COLUMN wishlist_items.completed IS 'Flag indicating item was purchased/received';
|
||||||
|
COMMENT ON COLUMN wishlist_items.image_path IS 'Path to image file relative to uploads root';
|
||||||
|
|
||||||
|
-- Task conditions table (depends on tasks)
|
||||||
|
CREATE TABLE task_conditions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT unique_task_condition UNIQUE (task_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_task_conditions_task_id ON task_conditions(task_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE task_conditions IS 'Reusable unlock conditions based on task completion';
|
||||||
|
|
||||||
|
-- Score conditions table (depends on projects, users)
|
||||||
|
CREATE TABLE score_conditions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
required_points NUMERIC(10,4) NOT NULL,
|
||||||
|
start_date DATE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT unique_score_condition UNIQUE (project_id, required_points, start_date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_score_conditions_project_id ON score_conditions(project_id);
|
||||||
|
CREATE INDEX idx_score_conditions_user_id ON score_conditions(user_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE score_conditions IS 'Reusable unlock conditions based on project points';
|
||||||
|
COMMENT ON COLUMN score_conditions.start_date IS 'Date from which to start counting points. NULL means count all time.';
|
||||||
|
|
||||||
|
-- Wishlist conditions table (depends on wishlist_items, task_conditions, score_conditions, users)
|
||||||
|
CREATE TABLE wishlist_conditions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
wishlist_item_id INTEGER NOT NULL REFERENCES wishlist_items(id) ON DELETE CASCADE,
|
||||||
|
task_condition_id INTEGER REFERENCES task_conditions(id) ON DELETE CASCADE,
|
||||||
|
score_condition_id INTEGER REFERENCES score_conditions(id) ON DELETE CASCADE,
|
||||||
|
display_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT check_exactly_one_condition CHECK (
|
||||||
|
(task_condition_id IS NOT NULL AND score_condition_id IS NULL) OR
|
||||||
|
(task_condition_id IS NULL AND score_condition_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wishlist_conditions_item_id ON wishlist_conditions(wishlist_item_id);
|
||||||
|
CREATE INDEX idx_wishlist_conditions_item_order ON wishlist_conditions(wishlist_item_id, display_order);
|
||||||
|
CREATE INDEX idx_wishlist_conditions_task_condition_id ON wishlist_conditions(task_condition_id);
|
||||||
|
CREATE INDEX idx_wishlist_conditions_score_condition_id ON wishlist_conditions(score_condition_id);
|
||||||
|
CREATE INDEX idx_wishlist_conditions_user_id ON wishlist_conditions(user_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE wishlist_conditions IS 'Links between wishlist items and unlock conditions. Multiple conditions per item use AND logic.';
|
||||||
|
COMMENT ON COLUMN wishlist_conditions.display_order IS 'Order for displaying conditions in UI';
|
||||||
|
COMMENT ON COLUMN wishlist_conditions.user_id IS 'Owner of this condition. Each user has their own goals on shared boards';
|
||||||
|
|
||||||
|
-- Refresh tokens table (depends on users)
|
||||||
|
CREATE TABLE refresh_tokens (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash VARCHAR(255) NOT NULL,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||||
|
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Materialized Views
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Weekly report materialized view
|
||||||
|
CREATE MATERIALIZED VIEW weekly_report_mv AS
|
||||||
|
SELECT
|
||||||
|
p.id AS project_id,
|
||||||
|
agg.report_year,
|
||||||
|
agg.report_week,
|
||||||
|
COALESCE(agg.total_score, 0.0000) AS total_score,
|
||||||
|
CASE
|
||||||
|
WHEN wg.max_score IS NULL THEN COALESCE(agg.total_score, 0.0000)
|
||||||
|
ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_score)
|
||||||
|
END AS normalized_total_score
|
||||||
|
FROM
|
||||||
|
projects p
|
||||||
|
LEFT JOIN
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
n.project_id,
|
||||||
|
EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year,
|
||||||
|
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
|
||||||
|
SUM(n.score) AS total_score
|
||||||
|
FROM
|
||||||
|
nodes n
|
||||||
|
JOIN
|
||||||
|
entries e ON n.entry_id = e.id
|
||||||
|
GROUP BY
|
||||||
|
1, 2, 3
|
||||||
|
) agg
|
||||||
|
ON p.id = agg.project_id
|
||||||
|
LEFT JOIN
|
||||||
|
weekly_goals wg
|
||||||
|
ON wg.project_id = p.id
|
||||||
|
AND wg.goal_year = agg.report_year
|
||||||
|
AND wg.goal_week = agg.report_week
|
||||||
|
WHERE
|
||||||
|
p.deleted = FALSE
|
||||||
|
ORDER BY
|
||||||
|
p.id, agg.report_year, agg.report_week
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
CREATE INDEX idx_weekly_report_mv_project_year_week ON weekly_report_mv(project_id, report_year, report_week);
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_score snapshot.';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
COMMENT ON TABLE configs IS 'Test configurations (words_count, max_cards, dictionary associations). Linked to tasks via tasks.config_id.';
|
||||||
|
COMMENT ON TABLE wishlist_boards IS 'Wishlist boards for organizing and sharing wishes';
|
||||||
|
COMMENT ON COLUMN wishlist_boards.invite_token IS 'Token for invite link, NULL = disabled';
|
||||||
|
COMMENT ON COLUMN wishlist_boards.invite_enabled IS 'Whether invite link is active';
|
||||||
|
COMMENT ON TABLE wishlist_board_members IS 'Users who joined boards via invite link (not owners)';
|
||||||
|
COMMENT ON COLUMN wishlist_items.author_id IS 'User who created this item (may differ from board owner on shared boards)';
|
||||||
|
COMMENT ON COLUMN wishlist_items.board_id IS 'Board this item belongs to';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Additional Tables
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Eateries table
|
||||||
|
CREATE TABLE eateries (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255),
|
||||||
|
address VARCHAR(255),
|
||||||
|
type VARCHAR(50),
|
||||||
|
distance DOUBLE PRECISION
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Interesting places table
|
||||||
|
CREATE TABLE interesting_places (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
city TEXT,
|
||||||
|
description TEXT,
|
||||||
|
added_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
is_visited BOOLEAN,
|
||||||
|
phone_number TEXT,
|
||||||
|
address TEXT,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Music groups table
|
||||||
|
CREATE TABLE music_groups (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
possible_locations TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- N8N chat histories table
|
||||||
|
CREATE TABLE n8n_chat_histories (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
session_id VARCHAR(255) NOT NULL,
|
||||||
|
message JSONB NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Places to visit table
|
||||||
|
CREATE TABLE places_to_visit (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
city TEXT,
|
||||||
|
description TEXT,
|
||||||
|
added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
is_visited BOOLEAN DEFAULT FALSE,
|
||||||
|
phone_number TEXT,
|
||||||
|
address TEXT,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Restaurants table
|
||||||
|
CREATE TABLE restaurants (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255),
|
||||||
|
address VARCHAR(255),
|
||||||
|
contact_info VARCHAR(255)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Upcoming concerts table (depends on music_groups)
|
||||||
|
CREATE TABLE upcoming_concerts (
|
||||||
|
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
group_id INTEGER NOT NULL REFERENCES music_groups(id),
|
||||||
|
scheduled_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
venue TEXT,
|
||||||
|
city TEXT,
|
||||||
|
tickets_url TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_unique_concert ON upcoming_concerts(scheduled_at, city, group_id);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Rollback migration: This migration cannot be automatically rolled back
|
||||||
|
-- The user_id values were corrected from projects.user_id, so reverting would
|
||||||
|
-- require knowing the original incorrect values, which is not possible.
|
||||||
|
-- If rollback is needed, you would need to manually restore from a backup.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration: Fix weekly_goals.user_id by updating it from projects.user_id
|
||||||
|
-- This migration fixes the issue where weekly_goals.user_id was incorrectly set to NULL or wrong user_id
|
||||||
|
-- It updates all weekly_goals records to have the correct user_id from their associated project
|
||||||
|
|
||||||
|
UPDATE weekly_goals wg
|
||||||
|
SET user_id = p.user_id
|
||||||
|
FROM projects p
|
||||||
|
WHERE wg.project_id = p.id
|
||||||
|
AND (wg.user_id IS NULL OR wg.user_id != p.user_id);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Rollback migration: Remove covering index for reward_configs
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_reward_configs_task_id_covering;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Migration: Add covering index for reward_configs to optimize subtask rewards queries
|
||||||
|
-- Date: 2026-01-26
|
||||||
|
--
|
||||||
|
-- This migration adds a covering index to optimize queries that load rewards for multiple subtasks.
|
||||||
|
-- The index includes all columns needed for the query, allowing PostgreSQL to perform
|
||||||
|
-- index-only scans without accessing the main table.
|
||||||
|
--
|
||||||
|
-- Covering index for reward_configs query
|
||||||
|
-- Includes all columns needed for rewards selection to avoid table lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reward_configs_task_id_covering
|
||||||
|
ON reward_configs(task_id, position)
|
||||||
|
INCLUDE (id, project_id, value, use_progression);
|
||||||
|
|
||||||
|
COMMENT ON INDEX idx_reward_configs_task_id_covering IS 'Covering index for rewards query - includes all selected columns to avoid table lookups. Enables index-only scans for better performance when loading rewards for multiple tasks.';
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
-- Migration: Revert optimization of weekly_report_mv
|
||||||
|
-- Date: 2026-01-26
|
||||||
|
--
|
||||||
|
-- This migration reverts:
|
||||||
|
-- 1. Removes created_date column from nodes table
|
||||||
|
-- 2. Drops indexes
|
||||||
|
-- 3. Restores MV to original structure (include current week, use entries.created_date)
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Step 1: Recreate MV with original structure
|
||||||
|
-- ============================================
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW weekly_report_mv AS
|
||||||
|
SELECT
|
||||||
|
p.id AS project_id,
|
||||||
|
agg.report_year,
|
||||||
|
agg.report_week,
|
||||||
|
COALESCE(agg.total_score, 0.0000) AS total_score,
|
||||||
|
CASE
|
||||||
|
WHEN wg.max_score IS NULL THEN COALESCE(agg.total_score, 0.0000)
|
||||||
|
ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_score)
|
||||||
|
END AS normalized_total_score
|
||||||
|
FROM
|
||||||
|
projects p
|
||||||
|
LEFT JOIN
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
n.project_id,
|
||||||
|
EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year,
|
||||||
|
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
|
||||||
|
SUM(n.score) AS total_score
|
||||||
|
FROM
|
||||||
|
nodes n
|
||||||
|
JOIN
|
||||||
|
entries e ON n.entry_id = e.id
|
||||||
|
GROUP BY
|
||||||
|
1, 2, 3
|
||||||
|
) agg
|
||||||
|
ON p.id = agg.project_id
|
||||||
|
LEFT JOIN
|
||||||
|
weekly_goals wg
|
||||||
|
ON wg.project_id = p.id
|
||||||
|
AND wg.goal_year = agg.report_year
|
||||||
|
AND wg.goal_week = agg.report_week
|
||||||
|
WHERE
|
||||||
|
p.deleted = FALSE
|
||||||
|
ORDER BY
|
||||||
|
p.id, agg.report_year, agg.report_week
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
CREATE INDEX idx_weekly_report_mv_project_year_week
|
||||||
|
ON weekly_report_mv(project_id, report_year, report_week);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Step 2: Drop indexes
|
||||||
|
-- ============================================
|
||||||
|
DROP INDEX IF EXISTS idx_nodes_project_user_created_date;
|
||||||
|
DROP INDEX IF EXISTS idx_nodes_created_date_user;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Step 3: Remove created_date column from nodes
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE nodes
|
||||||
|
DROP COLUMN IF EXISTS created_date;
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_score snapshot.';
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
-- Migration: Optimize weekly_report_mv by denormalizing created_date into nodes and excluding current week from MV
|
||||||
|
-- Date: 2026-01-26
|
||||||
|
--
|
||||||
|
-- This migration:
|
||||||
|
-- 1. Adds created_date column to nodes table (denormalization to avoid JOIN with entries)
|
||||||
|
-- 2. Populates existing data from entries
|
||||||
|
-- 3. Creates indexes for optimized queries
|
||||||
|
-- 4. Updates MV to exclude current week and use nodes.created_date instead of entries.created_date
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Step 1: Add created_date column to nodes
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE nodes
|
||||||
|
ADD COLUMN created_date TIMESTAMP WITH TIME ZONE;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Step 2: Populate existing data from entries
|
||||||
|
-- ============================================
|
||||||
|
UPDATE nodes n
|
||||||
|
SET created_date = e.created_date
|
||||||
|
FROM entries e
|
||||||
|
WHERE n.entry_id = e.id;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Step 3: Set NOT NULL constraint
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE nodes
|
||||||
|
ALTER COLUMN created_date SET NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Step 4: Create indexes for optimized queries
|
||||||
|
-- ============================================
|
||||||
|
-- Index for filtering by date and user (for current week queries)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nodes_created_date_user
|
||||||
|
ON nodes(created_date, user_id);
|
||||||
|
|
||||||
|
-- Index for queries with grouping by project (for current week queries)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nodes_project_user_created_date
|
||||||
|
ON nodes(project_id, user_id, created_date);
|
||||||
|
|
||||||
|
COMMENT ON INDEX idx_nodes_created_date_user IS 'Index for filtering nodes by created_date and user_id - optimized for current week queries';
|
||||||
|
COMMENT ON INDEX idx_nodes_project_user_created_date IS 'Index for grouping nodes by project, user and created_date - optimized for current week aggregation queries';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Step 5: Recreate MV to exclude current week and use nodes.created_date
|
||||||
|
-- ============================================
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW weekly_report_mv AS
|
||||||
|
SELECT
|
||||||
|
p.id AS project_id,
|
||||||
|
agg.report_year,
|
||||||
|
agg.report_week,
|
||||||
|
COALESCE(agg.total_score, 0.0000) AS total_score,
|
||||||
|
CASE
|
||||||
|
WHEN wg.max_score IS NULL THEN COALESCE(agg.total_score, 0.0000)
|
||||||
|
ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_score)
|
||||||
|
END AS normalized_total_score
|
||||||
|
FROM
|
||||||
|
projects p
|
||||||
|
LEFT JOIN
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
n.project_id,
|
||||||
|
EXTRACT(ISOYEAR FROM n.created_date)::INTEGER AS report_year,
|
||||||
|
EXTRACT(WEEK FROM n.created_date)::INTEGER AS report_week,
|
||||||
|
SUM(n.score) AS total_score
|
||||||
|
FROM
|
||||||
|
nodes n
|
||||||
|
WHERE
|
||||||
|
-- Exclude current week: only include data from previous weeks
|
||||||
|
(EXTRACT(ISOYEAR FROM n.created_date)::INTEGER < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
|
||||||
|
OR (EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||||
|
AND EXTRACT(WEEK FROM n.created_date)::INTEGER < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
|
||||||
|
GROUP BY
|
||||||
|
1, 2, 3
|
||||||
|
) agg
|
||||||
|
ON p.id = agg.project_id
|
||||||
|
LEFT JOIN
|
||||||
|
weekly_goals wg
|
||||||
|
ON wg.project_id = p.id
|
||||||
|
AND wg.goal_year = agg.report_year
|
||||||
|
AND wg.goal_week = agg.report_week
|
||||||
|
WHERE
|
||||||
|
p.deleted = FALSE
|
||||||
|
ORDER BY
|
||||||
|
p.id, agg.report_year, agg.report_week
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
-- Recreate index on MV
|
||||||
|
CREATE INDEX idx_weekly_report_mv_project_year_week
|
||||||
|
ON weekly_report_mv(project_id, report_year, report_week);
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_score snapshot. Contains only historical data (excludes current week). Uses nodes.created_date (denormalized) instead of entries.created_date.';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Migration: Remove task drafts tables
|
||||||
|
-- Date: 2026-01-26
|
||||||
|
--
|
||||||
|
-- This migration removes tables created for task drafts
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS task_draft_subtasks;
|
||||||
|
DROP TABLE IF EXISTS task_drafts;
|
||||||
45
play-life-backend/migrations/000005_add_task_drafts.up.sql
Normal file
45
play-life-backend/migrations/000005_add_task_drafts.up.sql
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
-- Migration: Add task drafts tables
|
||||||
|
-- Date: 2026-01-26
|
||||||
|
--
|
||||||
|
-- This migration creates tables for storing task drafts:
|
||||||
|
-- 1. task_drafts - main table for task drafts with progression value and auto_complete flag
|
||||||
|
-- 2. task_draft_subtasks - stores only checked subtask IDs for each draft
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: task_drafts
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE task_drafts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
progression_value NUMERIC(10,4),
|
||||||
|
auto_complete BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
UNIQUE(task_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_task_drafts_task_id ON task_drafts(task_id);
|
||||||
|
CREATE INDEX idx_task_drafts_user_id ON task_drafts(user_id);
|
||||||
|
CREATE INDEX idx_task_drafts_auto_complete ON task_drafts(auto_complete) WHERE auto_complete = TRUE;
|
||||||
|
|
||||||
|
COMMENT ON TABLE task_drafts IS 'Stores draft states for tasks with progression value and auto-complete flag';
|
||||||
|
COMMENT ON COLUMN task_drafts.progression_value IS 'Saved progression value from user input';
|
||||||
|
COMMENT ON COLUMN task_drafts.auto_complete IS 'Flag indicating task should be auto-completed at end of day (23:55)';
|
||||||
|
COMMENT ON COLUMN task_drafts.task_id IS 'Reference to task. UNIQUE constraint ensures one draft per task';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: task_draft_subtasks
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE task_draft_subtasks (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
task_draft_id INTEGER REFERENCES task_drafts(id) ON DELETE CASCADE,
|
||||||
|
subtask_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(task_draft_id, subtask_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_task_draft_subtasks_task_draft_id ON task_draft_subtasks(task_draft_id);
|
||||||
|
CREATE INDEX idx_task_draft_subtasks_subtask_id ON task_draft_subtasks(subtask_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE task_draft_subtasks IS 'Stores only checked subtask IDs for each draft. If subtask is not in this table, it means it is unchecked';
|
||||||
|
COMMENT ON COLUMN task_draft_subtasks.subtask_id IS 'Reference to subtask task. Only checked subtasks are stored here';
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Migration: Revert wishlist_id unique index fix
|
||||||
|
-- Date: 2026-01-30
|
||||||
|
--
|
||||||
|
-- This migration reverts the composite unique index back to the original
|
||||||
|
-- unique index that only checked wishlist_id.
|
||||||
|
|
||||||
|
-- Drop the composite unique index
|
||||||
|
DROP INDEX IF EXISTS idx_tasks_wishlist_id_user_id_unique;
|
||||||
|
|
||||||
|
-- Restore the original unique index on wishlist_id only
|
||||||
|
CREATE UNIQUE INDEX idx_tasks_wishlist_id_unique
|
||||||
|
ON tasks(wishlist_id)
|
||||||
|
WHERE wishlist_id IS NOT NULL AND deleted = FALSE;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration: Fix wishlist_id unique index to allow multiple users
|
||||||
|
-- Date: 2026-01-30
|
||||||
|
--
|
||||||
|
-- This migration fixes the unique index on wishlist_id to allow multiple users
|
||||||
|
-- to create tasks for the same wishlist item. The old index only checked wishlist_id,
|
||||||
|
-- but now we need a composite unique index on (wishlist_id, user_id).
|
||||||
|
|
||||||
|
-- Drop the old unique index that only checked wishlist_id
|
||||||
|
DROP INDEX IF EXISTS idx_tasks_wishlist_id_unique;
|
||||||
|
|
||||||
|
-- Create a new composite unique index on (wishlist_id, user_id)
|
||||||
|
-- This allows multiple users to have tasks for the same wishlist item,
|
||||||
|
-- but prevents the same user from having multiple tasks for the same wishlist item
|
||||||
|
CREATE UNIQUE INDEX idx_tasks_wishlist_id_user_id_unique
|
||||||
|
ON tasks(wishlist_id, user_id)
|
||||||
|
WHERE wishlist_id IS NOT NULL AND deleted = FALSE;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Migration: Drop projects_median_mv materialized view
|
||||||
|
-- Date: 2026-01-30
|
||||||
|
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- Migration: Add projects_median_mv materialized view
|
||||||
|
-- Date: 2026-01-30
|
||||||
|
--
|
||||||
|
-- This migration creates a materialized view that calculates the median score
|
||||||
|
-- for each project based on the last 12 weeks of historical data from weekly_report_mv.
|
||||||
|
-- The view includes user_id to support multi-tenant queries.
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW projects_median_mv AS
|
||||||
|
SELECT
|
||||||
|
p.id AS project_id,
|
||||||
|
p.user_id,
|
||||||
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY normalized_total_score) AS median_score
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
project_id,
|
||||||
|
normalized_total_score,
|
||||||
|
report_year,
|
||||||
|
report_week,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
|
||||||
|
FROM weekly_report_mv
|
||||||
|
WHERE
|
||||||
|
(report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
|
||||||
|
OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||||
|
AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
|
||||||
|
) sub
|
||||||
|
JOIN projects p ON p.id = sub.project_id
|
||||||
|
WHERE rn <= 12 AND p.deleted = FALSE
|
||||||
|
GROUP BY p.id, p.user_id
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
CREATE INDEX idx_projects_median_mv_project_id ON projects_median_mv(project_id);
|
||||||
|
CREATE INDEX idx_projects_median_mv_user_id ON projects_median_mv(user_id);
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW projects_median_mv IS 'Materialized view calculating median score for each project based on last 12 weeks of historical data. Includes user_id for multi-tenant support.';
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- Migration: Revert median calculation back to 12 weeks
|
||||||
|
-- Date: 2026-02-02
|
||||||
|
--
|
||||||
|
-- This migration reverts projects_median_mv back to using 12 weeks.
|
||||||
|
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW projects_median_mv AS
|
||||||
|
SELECT
|
||||||
|
p.id AS project_id,
|
||||||
|
p.user_id,
|
||||||
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY normalized_total_score) AS median_score
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
project_id,
|
||||||
|
normalized_total_score,
|
||||||
|
report_year,
|
||||||
|
report_week,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
|
||||||
|
FROM weekly_report_mv
|
||||||
|
WHERE
|
||||||
|
(report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
|
||||||
|
OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||||
|
AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
|
||||||
|
) sub
|
||||||
|
JOIN projects p ON p.id = sub.project_id
|
||||||
|
WHERE rn <= 12 AND p.deleted = FALSE
|
||||||
|
GROUP BY p.id, p.user_id
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
CREATE INDEX idx_projects_median_mv_project_id ON projects_median_mv(project_id);
|
||||||
|
CREATE INDEX idx_projects_median_mv_user_id ON projects_median_mv(user_id);
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW projects_median_mv IS 'Materialized view calculating median score for each project based on last 12 weeks of historical data. Includes user_id for multi-tenant support.';
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Migration: Change median calculation from 12 weeks to 4 weeks
|
||||||
|
-- Date: 2026-02-02
|
||||||
|
--
|
||||||
|
-- This migration updates projects_median_mv to calculate median based on
|
||||||
|
-- the last 4 weeks instead of 12 weeks.
|
||||||
|
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS projects_median_mv;
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW projects_median_mv AS
|
||||||
|
SELECT
|
||||||
|
p.id AS project_id,
|
||||||
|
p.user_id,
|
||||||
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY normalized_total_score) AS median_score
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
project_id,
|
||||||
|
normalized_total_score,
|
||||||
|
report_year,
|
||||||
|
report_week,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
|
||||||
|
FROM weekly_report_mv
|
||||||
|
WHERE
|
||||||
|
(report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
|
||||||
|
OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||||
|
AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
|
||||||
|
) sub
|
||||||
|
JOIN projects p ON p.id = sub.project_id
|
||||||
|
WHERE rn <= 4 AND p.deleted = FALSE
|
||||||
|
GROUP BY p.id, p.user_id
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
CREATE INDEX idx_projects_median_mv_project_id ON projects_median_mv(project_id);
|
||||||
|
CREATE INDEX idx_projects_median_mv_user_id ON projects_median_mv(user_id);
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW projects_median_mv IS 'Materialized view calculating median score for each project based on last 4 weeks of historical data. Includes user_id for multi-tenant support.';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration: Remove is_admin field from users table
|
||||||
|
-- Date: 2026-02-02
|
||||||
|
--
|
||||||
|
-- This migration reverts the addition of is_admin field.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_users_is_admin;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN IF EXISTS is_admin;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Migration: Add is_admin field to users table
|
||||||
|
-- Date: 2026-02-02
|
||||||
|
--
|
||||||
|
-- This migration adds is_admin boolean field to users table to identify admin users.
|
||||||
|
-- Default value is FALSE, so existing users will not become admins automatically.
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE INDEX idx_users_is_admin ON users(is_admin);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.is_admin IS 'Indicates if the user has admin privileges';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration: Remove project_id field from wishlist_items table
|
||||||
|
-- Date: 2026-02-02
|
||||||
|
--
|
||||||
|
-- This migration reverts the addition of project_id field.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_wishlist_items_project_id;
|
||||||
|
|
||||||
|
ALTER TABLE wishlist_items
|
||||||
|
DROP COLUMN IF EXISTS project_id;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Migration: Add project_id field to wishlist_items table
|
||||||
|
-- Date: 2026-02-02
|
||||||
|
--
|
||||||
|
-- This migration adds project_id field to wishlist_items table to allow
|
||||||
|
-- grouping wishlist items by project. The field is nullable, so existing
|
||||||
|
-- items without a project will remain valid.
|
||||||
|
|
||||||
|
ALTER TABLE wishlist_items
|
||||||
|
ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_wishlist_items_project_id ON wishlist_items(project_id);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN wishlist_items.project_id IS 'Project this wishlist item belongs to (optional)';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration: Remove color field from projects table
|
||||||
|
-- Date: 2026-02-02
|
||||||
|
--
|
||||||
|
-- This migration removes the color field from projects table.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_projects_color;
|
||||||
|
|
||||||
|
ALTER TABLE projects
|
||||||
|
DROP COLUMN IF EXISTS color;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- Migration: Add color field to projects table
|
||||||
|
-- Date: 2026-02-02
|
||||||
|
--
|
||||||
|
-- This migration adds color field to projects table to allow
|
||||||
|
-- custom color selection for projects. The field is NOT NULL,
|
||||||
|
-- and existing projects will be assigned colors from a predefined palette.
|
||||||
|
|
||||||
|
-- Добавляем поле color
|
||||||
|
ALTER TABLE projects
|
||||||
|
ADD COLUMN color VARCHAR(7) NOT NULL DEFAULT '#3B82F6';
|
||||||
|
|
||||||
|
-- Палитра из 30 контрастных цветов (синхронизирована с backend и frontend)
|
||||||
|
-- Заполняем существующие проекты цветами из палитры
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
colors TEXT[] := ARRAY[
|
||||||
|
'#EF4444', '#F97316', '#F59E0B', '#EAB308', '#84CC16',
|
||||||
|
'#22C55E', '#10B981', '#14B8A6', '#06B6D4', '#0EA5E9',
|
||||||
|
'#3B82F6', '#6366F1', '#8B5CF6', '#A855F7', '#D946EF',
|
||||||
|
'#EC4899', '#F43F5E', '#DC2626', '#EA580C', '#CA8A04',
|
||||||
|
'#65A30D', '#16A34A', '#059669', '#0D9488', '#0891B2',
|
||||||
|
'#0284C7', '#2563EB', '#4F46E5', '#7C3AED', '#9333EA'
|
||||||
|
];
|
||||||
|
project_record RECORD;
|
||||||
|
color_index INTEGER := 0;
|
||||||
|
BEGIN
|
||||||
|
-- Обновляем существующие проекты, присваивая им цвета из палитры
|
||||||
|
FOR project_record IN
|
||||||
|
SELECT id FROM projects ORDER BY id
|
||||||
|
LOOP
|
||||||
|
UPDATE projects
|
||||||
|
SET color = colors[1 + (color_index % array_length(colors, 1))]
|
||||||
|
WHERE id = project_record.id;
|
||||||
|
|
||||||
|
color_index := color_index + 1;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Убираем DEFAULT, так как теперь все проекты имеют цвет
|
||||||
|
ALTER TABLE projects
|
||||||
|
ALTER COLUMN color DROP DEFAULT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_projects_color ON projects(color);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN projects.color IS 'Project color in HEX format (e.g., #FF5733)';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration: Remove position field from tasks table
|
||||||
|
-- Date: 2026-02-02
|
||||||
|
--
|
||||||
|
-- This migration removes the position field from tasks table.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_tasks_parent_position;
|
||||||
|
|
||||||
|
ALTER TABLE tasks
|
||||||
|
DROP COLUMN IF EXISTS position;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Migration: Add position field to tasks table for subtasks ordering
|
||||||
|
-- Date: 2026-02-02
|
||||||
|
--
|
||||||
|
-- This migration adds position field to tasks table to allow
|
||||||
|
-- custom ordering of subtasks. The field is NULL for regular tasks
|
||||||
|
-- and contains position number for subtasks (tasks with parent_task_id).
|
||||||
|
|
||||||
|
-- Добавляем поле position
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN position INTEGER;
|
||||||
|
|
||||||
|
-- Заполняем позиции для всех существующих подзадач
|
||||||
|
-- Позиции присваиваются по порядку id в рамках каждой родительской задачи
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
parent_record RECORD;
|
||||||
|
subtask_record RECORD;
|
||||||
|
pos INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- Для каждой родительской задачи
|
||||||
|
FOR parent_record IN
|
||||||
|
SELECT DISTINCT parent_task_id
|
||||||
|
FROM tasks
|
||||||
|
WHERE parent_task_id IS NOT NULL
|
||||||
|
ORDER BY parent_task_id
|
||||||
|
LOOP
|
||||||
|
pos := 0;
|
||||||
|
-- Обновляем подзадачи этой родительской задачи
|
||||||
|
FOR subtask_record IN
|
||||||
|
SELECT id
|
||||||
|
FROM tasks
|
||||||
|
WHERE parent_task_id = parent_record.parent_task_id
|
||||||
|
AND deleted = FALSE
|
||||||
|
ORDER BY id
|
||||||
|
LOOP
|
||||||
|
UPDATE tasks
|
||||||
|
SET position = pos
|
||||||
|
WHERE id = subtask_record.id;
|
||||||
|
|
||||||
|
pos := pos + 1;
|
||||||
|
END LOOP;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Создаем индекс для быстрой сортировки подзадач
|
||||||
|
CREATE INDEX idx_tasks_parent_position ON tasks(parent_task_id, position)
|
||||||
|
WHERE parent_task_id IS NOT NULL AND deleted = FALSE;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN tasks.position IS 'Position of subtask within parent task. NULL for regular tasks.';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS tracking_invite_tokens;
|
||||||
|
DROP TABLE IF EXISTS user_tracking;
|
||||||
24
play-life-backend/migrations/000013_add_user_tracking.up.sql
Normal file
24
play-life-backend/migrations/000013_add_user_tracking.up.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- Таблица отслеживания между пользователями
|
||||||
|
CREATE TABLE user_tracking (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tracker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
tracked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT unique_tracking_pair UNIQUE (tracker_id, tracked_id),
|
||||||
|
CONSTRAINT no_self_tracking CHECK (tracker_id != tracked_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_user_tracking_tracker ON user_tracking(tracker_id);
|
||||||
|
CREATE INDEX idx_user_tracking_tracked ON user_tracking(tracked_id);
|
||||||
|
|
||||||
|
-- Таблица токенов приглашений (живут 1 час)
|
||||||
|
CREATE TABLE tracking_invite_tokens (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tracking_invite_tokens_token ON tracking_invite_tokens(token);
|
||||||
|
CREATE INDEX idx_tracking_invite_tokens_user ON tracking_invite_tokens(user_id);
|
||||||
36
play-life-backend/migrations/000014_add_group_name.down.sql
Normal file
36
play-life-backend/migrations/000014_add_group_name.down.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
-- Migration: Remove group_name field from wishlist_items and tasks tables
|
||||||
|
-- Date: 2026-02-XX
|
||||||
|
--
|
||||||
|
-- This migration reverses the changes made in 000014_add_group_name.up.sql
|
||||||
|
|
||||||
|
-- Step 1: Drop materialized view
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS user_group_suggestions_mv;
|
||||||
|
|
||||||
|
-- Step 2: Drop indexes on group_name
|
||||||
|
DROP INDEX IF EXISTS idx_tasks_group_name;
|
||||||
|
DROP INDEX IF EXISTS idx_wishlist_items_group_name;
|
||||||
|
|
||||||
|
-- Step 3: Remove group_name from tasks
|
||||||
|
ALTER TABLE tasks
|
||||||
|
DROP COLUMN group_name;
|
||||||
|
|
||||||
|
-- Step 4: Add back project_id to wishlist_items
|
||||||
|
ALTER TABLE wishlist_items
|
||||||
|
ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Step 5: Try to restore project_id from group_name (if possible)
|
||||||
|
-- Note: This is best-effort, as group_name might not match project names exactly
|
||||||
|
UPDATE wishlist_items wi
|
||||||
|
SET project_id = p.id
|
||||||
|
FROM projects p
|
||||||
|
WHERE wi.group_name = p.name
|
||||||
|
AND wi.group_name IS NOT NULL
|
||||||
|
AND wi.group_name != ''
|
||||||
|
AND p.deleted = FALSE;
|
||||||
|
|
||||||
|
-- Step 6: Create index on project_id
|
||||||
|
CREATE INDEX idx_wishlist_items_project_id ON wishlist_items(project_id);
|
||||||
|
|
||||||
|
-- Step 7: Remove group_name from wishlist_items
|
||||||
|
ALTER TABLE wishlist_items
|
||||||
|
DROP COLUMN group_name;
|
||||||
60
play-life-backend/migrations/000014_add_group_name.up.sql
Normal file
60
play-life-backend/migrations/000014_add_group_name.up.sql
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
-- Migration: Add group_name field to wishlist_items and tasks tables
|
||||||
|
-- Date: 2026-02-XX
|
||||||
|
--
|
||||||
|
-- This migration:
|
||||||
|
-- 1. Adds group_name field to wishlist_items (replacing project_id)
|
||||||
|
-- 2. Migrates existing data from project_id to group_name
|
||||||
|
-- 3. Removes project_id column from wishlist_items
|
||||||
|
-- 4. Adds group_name field to tasks
|
||||||
|
-- 5. Creates materialized view for group suggestions
|
||||||
|
|
||||||
|
-- Step 1: Add group_name to wishlist_items
|
||||||
|
ALTER TABLE wishlist_items
|
||||||
|
ADD COLUMN group_name VARCHAR(255);
|
||||||
|
|
||||||
|
-- Step 2: Migrate existing data from project_id to group_name
|
||||||
|
UPDATE wishlist_items wi
|
||||||
|
SET group_name = p.name
|
||||||
|
FROM projects p
|
||||||
|
WHERE wi.project_id = p.id AND wi.project_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Step 3: Remove project_id column and its index
|
||||||
|
DROP INDEX IF EXISTS idx_wishlist_items_project_id;
|
||||||
|
ALTER TABLE wishlist_items
|
||||||
|
DROP COLUMN project_id;
|
||||||
|
|
||||||
|
-- Step 4: Add group_name to tasks
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN group_name VARCHAR(255);
|
||||||
|
|
||||||
|
-- Step 5: Create indexes on group_name
|
||||||
|
CREATE INDEX idx_wishlist_items_group_name ON wishlist_items(group_name) WHERE group_name IS NOT NULL;
|
||||||
|
CREATE INDEX idx_tasks_group_name ON tasks(group_name) WHERE group_name IS NOT NULL;
|
||||||
|
|
||||||
|
-- Step 6: Create materialized view for group suggestions
|
||||||
|
CREATE MATERIALIZED VIEW user_group_suggestions_mv AS
|
||||||
|
SELECT DISTINCT user_id, group_name FROM (
|
||||||
|
-- Желания пользователя (собственные)
|
||||||
|
SELECT wi.user_id, wi.group_name FROM wishlist_items wi
|
||||||
|
WHERE wi.deleted = FALSE AND wi.group_name IS NOT NULL AND wi.group_name != ''
|
||||||
|
UNION
|
||||||
|
-- Желания с досок, на которых пользователь участник
|
||||||
|
SELECT wbm.user_id, wi.group_name FROM wishlist_items wi
|
||||||
|
JOIN wishlist_board_members wbm ON wi.board_id = wbm.board_id
|
||||||
|
WHERE wi.deleted = FALSE AND wi.group_name IS NOT NULL AND wi.group_name != ''
|
||||||
|
UNION
|
||||||
|
-- Задачи пользователя
|
||||||
|
SELECT t.user_id, t.group_name FROM tasks t
|
||||||
|
WHERE t.deleted = FALSE AND t.group_name IS NOT NULL AND t.group_name != ''
|
||||||
|
UNION
|
||||||
|
-- Имена проектов пользователя
|
||||||
|
SELECT p.user_id, p.name FROM projects p
|
||||||
|
WHERE p.deleted = FALSE
|
||||||
|
) sub;
|
||||||
|
|
||||||
|
-- Step 7: Create unique index for CONCURRENT refresh
|
||||||
|
CREATE UNIQUE INDEX idx_user_group_suggestions_mv_user_group ON user_group_suggestions_mv(user_id, group_name);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN wishlist_items.group_name IS 'Group name for wishlist item (free text, replaces project_id)';
|
||||||
|
COMMENT ON COLUMN tasks.group_name IS 'Group name for task (free text)';
|
||||||
|
COMMENT ON MATERIALIZED VIEW user_group_suggestions_mv IS 'Materialized view for group name suggestions per user';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS fitbit_daily_stats;
|
||||||
|
DROP TABLE IF EXISTS fitbit_integrations;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- Fitbit integrations table (depends on users)
|
||||||
|
CREATE TABLE fitbit_integrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
fitbit_user_id VARCHAR(255),
|
||||||
|
access_token TEXT,
|
||||||
|
refresh_token TEXT,
|
||||||
|
token_expires_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
goal_steps_min INTEGER DEFAULT 8000,
|
||||||
|
goal_steps_max INTEGER DEFAULT 10000,
|
||||||
|
goal_floors_min INTEGER DEFAULT 8,
|
||||||
|
goal_floors_max INTEGER DEFAULT 10,
|
||||||
|
goal_azm_min INTEGER DEFAULT 22,
|
||||||
|
goal_azm_max INTEGER DEFAULT 44,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fitbit_integrations_user_id_unique UNIQUE (user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_fitbit_integrations_user_id ON fitbit_integrations(user_id);
|
||||||
|
CREATE UNIQUE INDEX idx_fitbit_integrations_fitbit_user_id ON fitbit_integrations(fitbit_user_id) WHERE fitbit_user_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Fitbit daily stats table (depends on users and fitbit_integrations)
|
||||||
|
CREATE TABLE fitbit_daily_stats (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
steps INTEGER DEFAULT 0,
|
||||||
|
floors INTEGER DEFAULT 0,
|
||||||
|
active_zone_minutes INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fitbit_daily_stats_user_date_unique UNIQUE (user_id, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_fitbit_daily_stats_user_id ON fitbit_daily_stats(user_id);
|
||||||
|
CREATE INDEX idx_fitbit_daily_stats_date ON fitbit_daily_stats(date);
|
||||||
|
CREATE INDEX idx_fitbit_daily_stats_user_date ON fitbit_daily_stats(user_id, date);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Migration: Drop project_score_sample_mv materialized view
|
||||||
|
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Migration: Add project_score_sample_mv materialized view
|
||||||
|
--
|
||||||
|
-- One row per (project_id, score, user_id): sum of nodes.score per entry,
|
||||||
|
-- representative entry_message (latest by date). Used for admin display and reporting.
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW project_score_sample_mv AS
|
||||||
|
WITH entry_scores AS (
|
||||||
|
SELECT
|
||||||
|
n.project_id,
|
||||||
|
n.entry_id,
|
||||||
|
n.user_id,
|
||||||
|
SUM(n.score) AS score,
|
||||||
|
MAX(n.created_date) AS created_date
|
||||||
|
FROM nodes n
|
||||||
|
GROUP BY n.project_id, n.entry_id, n.user_id
|
||||||
|
)
|
||||||
|
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
|
||||||
|
es.project_id,
|
||||||
|
es.score,
|
||||||
|
e.text AS entry_message,
|
||||||
|
es.user_id,
|
||||||
|
es.created_date
|
||||||
|
FROM entry_scores es
|
||||||
|
JOIN entries e ON e.id = es.entry_id
|
||||||
|
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
|
||||||
|
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): sum of nodes per entry, representative entry_message (latest by date).';
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Revert to previous MV definition (one row per project_id, score, user_id)
|
||||||
|
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW project_score_sample_mv AS
|
||||||
|
WITH entry_scores AS (
|
||||||
|
SELECT
|
||||||
|
n.project_id,
|
||||||
|
n.entry_id,
|
||||||
|
n.user_id,
|
||||||
|
SUM(n.score) AS score,
|
||||||
|
MAX(n.created_date) AS created_date
|
||||||
|
FROM nodes n
|
||||||
|
GROUP BY n.project_id, n.entry_id, n.user_id
|
||||||
|
)
|
||||||
|
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
|
||||||
|
es.project_id,
|
||||||
|
es.score,
|
||||||
|
e.text AS entry_message,
|
||||||
|
es.user_id,
|
||||||
|
es.created_date
|
||||||
|
FROM entry_scores es
|
||||||
|
JOIN entries e ON e.id = es.entry_id
|
||||||
|
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
|
||||||
|
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): sum of nodes per entry, representative entry_message (latest by date).';
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- Migration: Make entry_message unique per (project_id, user_id) in project_score_sample_mv
|
||||||
|
--
|
||||||
|
-- One row per (project_id, user_id, entry_message): choose the row with latest created_date.
|
||||||
|
-- Ensures the same entry_message does not repeat for different score values.
|
||||||
|
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW project_score_sample_mv AS
|
||||||
|
WITH entry_scores AS (
|
||||||
|
SELECT
|
||||||
|
n.project_id,
|
||||||
|
n.entry_id,
|
||||||
|
n.user_id,
|
||||||
|
SUM(n.score) AS score,
|
||||||
|
MAX(n.created_date) AS created_date
|
||||||
|
FROM nodes n
|
||||||
|
GROUP BY n.project_id, n.entry_id, n.user_id
|
||||||
|
),
|
||||||
|
with_message AS (
|
||||||
|
SELECT
|
||||||
|
es.project_id,
|
||||||
|
es.score,
|
||||||
|
e.text AS entry_message,
|
||||||
|
es.user_id,
|
||||||
|
es.created_date
|
||||||
|
FROM entry_scores es
|
||||||
|
JOIN entries e ON e.id = es.entry_id
|
||||||
|
)
|
||||||
|
SELECT DISTINCT ON (project_id, user_id, entry_message)
|
||||||
|
project_id,
|
||||||
|
score,
|
||||||
|
entry_message,
|
||||||
|
user_id,
|
||||||
|
created_date
|
||||||
|
FROM with_message
|
||||||
|
ORDER BY project_id, user_id, entry_message, created_date DESC
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
|
||||||
|
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, user_id, entry_message): representative row (latest by date). entry_message is unique per project and user.';
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- Revert to one row per (project_id, user_id, entry_message)
|
||||||
|
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW project_score_sample_mv AS
|
||||||
|
WITH entry_scores AS (
|
||||||
|
SELECT
|
||||||
|
n.project_id,
|
||||||
|
n.entry_id,
|
||||||
|
n.user_id,
|
||||||
|
SUM(n.score) AS score,
|
||||||
|
MAX(n.created_date) AS created_date
|
||||||
|
FROM nodes n
|
||||||
|
GROUP BY n.project_id, n.entry_id, n.user_id
|
||||||
|
),
|
||||||
|
with_message AS (
|
||||||
|
SELECT
|
||||||
|
es.project_id,
|
||||||
|
es.score,
|
||||||
|
e.text AS entry_message,
|
||||||
|
es.user_id,
|
||||||
|
es.created_date
|
||||||
|
FROM entry_scores es
|
||||||
|
JOIN entries e ON e.id = es.entry_id
|
||||||
|
)
|
||||||
|
SELECT DISTINCT ON (project_id, user_id, entry_message)
|
||||||
|
project_id,
|
||||||
|
score,
|
||||||
|
entry_message,
|
||||||
|
user_id,
|
||||||
|
created_date
|
||||||
|
FROM with_message
|
||||||
|
ORDER BY project_id, user_id, entry_message, created_date DESC
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
|
||||||
|
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, user_id, entry_message): representative row (latest by date).';
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Migration: One row per (project_id, user_id, score) in project_score_sample_mv
|
||||||
|
--
|
||||||
|
-- For each score value (per project and user) exactly one record; representative entry_message (latest by date).
|
||||||
|
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW project_score_sample_mv AS
|
||||||
|
WITH entry_scores AS (
|
||||||
|
SELECT
|
||||||
|
n.project_id,
|
||||||
|
n.entry_id,
|
||||||
|
n.user_id,
|
||||||
|
SUM(n.score) AS score,
|
||||||
|
MAX(n.created_date) AS created_date
|
||||||
|
FROM nodes n
|
||||||
|
GROUP BY n.project_id, n.entry_id, n.user_id
|
||||||
|
)
|
||||||
|
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
|
||||||
|
es.project_id,
|
||||||
|
es.score,
|
||||||
|
e.text AS entry_message,
|
||||||
|
es.user_id,
|
||||||
|
es.created_date
|
||||||
|
FROM entry_scores es
|
||||||
|
JOIN entries e ON e.id = es.entry_id
|
||||||
|
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
|
||||||
|
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): one record per score, representative entry_message (latest by date).';
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
-- Revert to one row per (project_id, score, user_id)
|
||||||
|
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW project_score_sample_mv AS
|
||||||
|
WITH entry_scores AS (
|
||||||
|
SELECT
|
||||||
|
n.project_id,
|
||||||
|
n.entry_id,
|
||||||
|
n.user_id,
|
||||||
|
SUM(n.score) AS score,
|
||||||
|
MAX(n.created_date) AS created_date
|
||||||
|
FROM nodes n
|
||||||
|
GROUP BY n.project_id, n.entry_id, n.user_id
|
||||||
|
)
|
||||||
|
SELECT DISTINCT ON (es.project_id, es.score, es.user_id)
|
||||||
|
es.project_id,
|
||||||
|
es.score,
|
||||||
|
e.text AS entry_message,
|
||||||
|
es.user_id,
|
||||||
|
es.created_date
|
||||||
|
FROM entry_scores es
|
||||||
|
JOIN entries e ON e.id = es.entry_id
|
||||||
|
ORDER BY es.project_id, es.score, es.user_id, es.created_date DESC
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
|
||||||
|
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, score, user_id): one record per score, representative entry_message (latest by date).';
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- Migration: One entry_message per (project_id, user_id) in project_score_sample_mv
|
||||||
|
--
|
||||||
|
-- One record per score (per project, user) and one record per entry_message per project.
|
||||||
|
-- DISTINCT ON (project_id, user_id, entry_message): same message with different scores → one row (latest by date).
|
||||||
|
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS project_score_sample_mv;
|
||||||
|
|
||||||
|
CREATE MATERIALIZED VIEW project_score_sample_mv AS
|
||||||
|
WITH entry_scores AS (
|
||||||
|
SELECT
|
||||||
|
n.project_id,
|
||||||
|
n.entry_id,
|
||||||
|
n.user_id,
|
||||||
|
SUM(n.score) AS score,
|
||||||
|
MAX(n.created_date) AS created_date
|
||||||
|
FROM nodes n
|
||||||
|
GROUP BY n.project_id, n.entry_id, n.user_id
|
||||||
|
),
|
||||||
|
with_message AS (
|
||||||
|
SELECT
|
||||||
|
es.project_id,
|
||||||
|
es.score,
|
||||||
|
e.text AS entry_message,
|
||||||
|
es.user_id,
|
||||||
|
es.created_date
|
||||||
|
FROM entry_scores es
|
||||||
|
JOIN entries e ON e.id = es.entry_id
|
||||||
|
)
|
||||||
|
SELECT DISTINCT ON (project_id, user_id, entry_message)
|
||||||
|
project_id,
|
||||||
|
score,
|
||||||
|
entry_message,
|
||||||
|
user_id,
|
||||||
|
created_date
|
||||||
|
FROM with_message
|
||||||
|
ORDER BY project_id, user_id, entry_message, created_date DESC
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
CREATE INDEX idx_project_score_sample_mv_project_score_user ON project_score_sample_mv(project_id, score, user_id);
|
||||||
|
CREATE INDEX idx_project_score_sample_mv_user_id ON project_score_sample_mv(user_id);
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW project_score_sample_mv IS 'One row per (project_id, user_id, entry_message): one record per score (chosen row), one entry_message per project; representative = latest by date.';
|
||||||
115
play-life-backend/migrations/README.md
Normal file
115
play-life-backend/migrations/README.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# Database Migrations
|
||||||
|
|
||||||
|
Этот каталог содержит SQL миграции для создания структуры базы данных проекта play-life.
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Создание базы данных с нуля
|
||||||
|
|
||||||
|
Выполните миграцию для создания всех таблиц и представлений:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -U your_user -d your_database -f 001_create_schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Или через docker-compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose exec db psql -U playeng -d playeng -f /migrations/001_create_schema.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура базы данных
|
||||||
|
|
||||||
|
### Таблицы
|
||||||
|
|
||||||
|
1. **projects** - Проекты
|
||||||
|
- `id` (SERIAL PRIMARY KEY)
|
||||||
|
- `name` (VARCHAR(255) NOT NULL, UNIQUE)
|
||||||
|
- `priority` (SMALLINT)
|
||||||
|
|
||||||
|
2. **entries** - Записи с текстом и датами создания
|
||||||
|
- `id` (SERIAL PRIMARY KEY)
|
||||||
|
- `text` (TEXT NOT NULL)
|
||||||
|
- `created_date` (TIMESTAMP WITH TIME ZONE NOT NULL, DEFAULT CURRENT_TIMESTAMP)
|
||||||
|
|
||||||
|
3. **nodes** - Узлы, связывающие проекты и записи
|
||||||
|
- `id` (SERIAL PRIMARY KEY)
|
||||||
|
- `project_id` (INTEGER NOT NULL, FK -> projects.id ON DELETE CASCADE)
|
||||||
|
- `entry_id` (INTEGER NOT NULL, FK -> entries.id ON DELETE CASCADE)
|
||||||
|
- `score` (NUMERIC(8,4))
|
||||||
|
|
||||||
|
4. **weekly_goals** - Недельные цели для проектов
|
||||||
|
- `id` (SERIAL PRIMARY KEY)
|
||||||
|
- `project_id` (INTEGER NOT NULL, FK -> projects.id ON DELETE CASCADE)
|
||||||
|
- `goal_year` (INTEGER NOT NULL)
|
||||||
|
- `goal_week` (INTEGER NOT NULL)
|
||||||
|
- `min_goal_score` (NUMERIC(10,4) NOT NULL, DEFAULT 0)
|
||||||
|
- `max_goal_score` (NUMERIC(10,4))
|
||||||
|
- `max_score` (NUMERIC(10,4), NULL) — snapshot max на неделю (заполняется только для новых недель)
|
||||||
|
- `priority` (SMALLINT)
|
||||||
|
- UNIQUE CONSTRAINT: `(project_id, goal_year, goal_week)`
|
||||||
|
|
||||||
|
### Materialized View
|
||||||
|
|
||||||
|
- **weekly_report_mv** - Агрегированные данные по неделям для каждого проекта
|
||||||
|
- `project_id` (INTEGER)
|
||||||
|
- `report_year` (INTEGER)
|
||||||
|
- `report_week` (INTEGER)
|
||||||
|
- `total_score` (NUMERIC)
|
||||||
|
- `normalized_total_score` (NUMERIC)
|
||||||
|
|
||||||
|
## Миграции
|
||||||
|
|
||||||
|
### Порядок применения миграций
|
||||||
|
|
||||||
|
1. **001_create_schema.sql** - Создание базовой структуры (таблицы, индексы, materialized view)
|
||||||
|
2. **002_add_dictionaries.sql** - Добавление таблиц для словарей
|
||||||
|
3. **003_remove_words_unique_constraint.sql** - Удаление уникального ограничения на words.name
|
||||||
|
4. **004_add_config_dictionaries.sql** - Добавление связи между конфигурациями и словарями
|
||||||
|
5. **005_fix_weekly_report_mv.sql** - Исправление использования ISOYEAR вместо YEAR для корректной работы на границе года
|
||||||
|
6. **006_fix_weekly_report_mv_structure.sql** - Исправление структуры view (добавление LEFT JOIN для включения всех проектов)
|
||||||
|
7. **026_weekly_goals_max_score.sql** - Добавление snapshot поля weekly_goals.max_score и удаление неиспользуемого actual_score
|
||||||
|
8. **027_add_normalized_total_score_to_weekly_report_mv.sql** - Добавление normalized_total_score в weekly_report_mv (ограничение total_score по max_score)
|
||||||
|
|
||||||
|
### Применение миграций
|
||||||
|
|
||||||
|
Для существующей базы данных применяйте миграции последовательно:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -U playeng -d playeng -f migrations/005_fix_weekly_report_mv.sql
|
||||||
|
psql -U playeng -d playeng -f migrations/006_fix_weekly_report_mv_structure.sql
|
||||||
|
psql -U playeng -d playeng -f migrations/026_weekly_goals_max_score.sql
|
||||||
|
psql -U playeng -d playeng -f migrations/027_add_normalized_total_score_to_weekly_report_mv.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Или через docker-compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose exec db psql -U playeng -d playeng -f /migrations/005_fix_weekly_report_mv.sql
|
||||||
|
docker-compose exec db psql -U playeng -d playeng -f /migrations/006_fix_weekly_report_mv_structure.sql
|
||||||
|
docker-compose exec db psql -U playeng -d playeng -f /migrations/026_weekly_goals_max_score.sql
|
||||||
|
docker-compose exec db psql -U playeng -d playeng -f /migrations/027_add_normalized_total_score_to_weekly_report_mv.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обновление Materialized View
|
||||||
|
|
||||||
|
После изменения данных в таблицах `nodes` или `entries`, необходимо обновить materialized view:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
REFRESH MATERIALIZED VIEW weekly_report_mv;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Связи между таблицами
|
||||||
|
|
||||||
|
- `nodes.project_id` → `projects.id` (ON DELETE CASCADE)
|
||||||
|
- `nodes.entry_id` → `entries.id` (ON DELETE CASCADE)
|
||||||
|
- `weekly_goals.project_id` → `projects.id` (ON DELETE CASCADE)
|
||||||
|
|
||||||
|
## Индексы
|
||||||
|
|
||||||
|
Созданы индексы для оптимизации запросов:
|
||||||
|
- `idx_nodes_project_id` на `nodes(project_id)`
|
||||||
|
- `idx_nodes_entry_id` на `nodes(entry_id)`
|
||||||
|
- `idx_weekly_goals_project_id` на `weekly_goals(project_id)`
|
||||||
|
- `idx_weekly_report_mv_project_year_week` на `weekly_report_mv(project_id, report_year, report_week)`
|
||||||
|
|
||||||
106
play-life-backend/migrations_old/001_create_schema.sql
Normal file
106
play-life-backend/migrations_old/001_create_schema.sql
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
-- Migration: Create database schema for play-life project
|
||||||
|
-- This script creates all tables and materialized views needed for the project
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: projects
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
priority SMALLINT,
|
||||||
|
CONSTRAINT unique_project_name UNIQUE (name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: entries
|
||||||
|
-- ============================================
|
||||||
|
-- This table stores entries with creation dates
|
||||||
|
-- Used in weekly_report_mv for grouping by week
|
||||||
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
created_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: nodes
|
||||||
|
-- ============================================
|
||||||
|
-- This table stores nodes linked to projects and entries
|
||||||
|
-- Contains score information used in weekly reports
|
||||||
|
CREATE TABLE IF NOT EXISTS nodes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
score NUMERIC(8,4)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create index on project_id for better join performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nodes_project_id ON nodes(project_id);
|
||||||
|
-- Create index on entry_id for better join performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nodes_entry_id ON nodes(entry_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: weekly_goals
|
||||||
|
-- ============================================
|
||||||
|
-- This table stores weekly goals for projects
|
||||||
|
CREATE TABLE IF NOT EXISTS weekly_goals (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
goal_year INTEGER NOT NULL,
|
||||||
|
goal_week INTEGER NOT NULL,
|
||||||
|
min_goal_score NUMERIC(10,4) NOT NULL DEFAULT 0,
|
||||||
|
max_goal_score NUMERIC(10,4),
|
||||||
|
actual_score NUMERIC(10,4) DEFAULT 0,
|
||||||
|
priority SMALLINT,
|
||||||
|
CONSTRAINT weekly_goals_project_id_goal_year_goal_week_key UNIQUE (project_id, goal_year, goal_week)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create index on project_id for better join performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weekly_goals_project_id ON weekly_goals(project_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Materialized View: weekly_report_mv
|
||||||
|
-- ============================================
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS weekly_report_mv AS
|
||||||
|
SELECT
|
||||||
|
p.id AS project_id,
|
||||||
|
agg.report_year,
|
||||||
|
agg.report_week,
|
||||||
|
-- Используем COALESCE для установки total_score в 0.0000, если нет данных (NULL)
|
||||||
|
COALESCE(agg.total_score, 0.0000) AS total_score
|
||||||
|
FROM
|
||||||
|
projects p
|
||||||
|
LEFT JOIN
|
||||||
|
(
|
||||||
|
-- 1. Предварительная агрегация: суммируем score по неделям
|
||||||
|
-- Используем ISOYEAR для корректной работы на границе года
|
||||||
|
SELECT
|
||||||
|
n.project_id,
|
||||||
|
EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year,
|
||||||
|
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
|
||||||
|
SUM(n.score) AS total_score
|
||||||
|
FROM
|
||||||
|
nodes n
|
||||||
|
JOIN
|
||||||
|
entries e ON n.entry_id = e.id
|
||||||
|
GROUP BY
|
||||||
|
1, 2, 3
|
||||||
|
) agg
|
||||||
|
-- 2. Присоединяем агрегированные данные ко ВСЕМ проектам
|
||||||
|
ON p.id = agg.project_id
|
||||||
|
ORDER BY
|
||||||
|
p.id, agg.report_year, agg.report_week;
|
||||||
|
|
||||||
|
-- Create index on materialized view for better query performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week
|
||||||
|
ON weekly_report_mv(project_id, report_year, report_week);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE projects IS 'Projects table storing project information with priority';
|
||||||
|
COMMENT ON TABLE entries IS 'Entries table storing entry creation timestamps';
|
||||||
|
COMMENT ON TABLE nodes IS 'Nodes table linking projects, entries and storing scores';
|
||||||
|
COMMENT ON TABLE weekly_goals IS 'Weekly goals for projects';
|
||||||
|
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries';
|
||||||
|
|
||||||
53
play-life-backend/migrations_old/002_add_dictionaries.sql
Normal file
53
play-life-backend/migrations_old/002_add_dictionaries.sql
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
-- Migration: Add dictionaries table and dictionary_id to words
|
||||||
|
-- This script creates the dictionaries table and adds dictionary_id field to words table
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: dictionaries
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS dictionaries (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Insert default dictionary "Все слова" with id = 0
|
||||||
|
-- Note: PostgreSQL SERIAL starts from 1, so we need to use a workaround
|
||||||
|
-- First, set the sequence to allow inserting 0, then insert, then reset sequence
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Set sequence to -1 so next value will be 0
|
||||||
|
PERFORM setval('dictionaries_id_seq', -1, false);
|
||||||
|
|
||||||
|
-- Insert the default dictionary with id = 0
|
||||||
|
INSERT INTO dictionaries (id, name)
|
||||||
|
VALUES (0, 'Все слова')
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Set the sequence to start from 1 (so next auto-increment will be 1)
|
||||||
|
PERFORM setval('dictionaries_id_seq', 1, false);
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Alter words table: Add dictionary_id column
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE words
|
||||||
|
ADD COLUMN IF NOT EXISTS dictionary_id INTEGER DEFAULT 0 REFERENCES dictionaries(id);
|
||||||
|
|
||||||
|
-- Update all existing words to have dictionary_id = 0
|
||||||
|
UPDATE words
|
||||||
|
SET dictionary_id = 0
|
||||||
|
WHERE dictionary_id IS NULL;
|
||||||
|
|
||||||
|
-- Make dictionary_id NOT NULL after setting default values
|
||||||
|
ALTER TABLE words
|
||||||
|
ALTER COLUMN dictionary_id SET NOT NULL,
|
||||||
|
ALTER COLUMN dictionary_id SET DEFAULT 0;
|
||||||
|
|
||||||
|
-- Create index on dictionary_id for better join performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_words_dictionary_id ON words(dictionary_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE dictionaries IS 'Dictionaries table storing dictionary information';
|
||||||
|
COMMENT ON COLUMN words.dictionary_id IS 'Reference to dictionary. Default is 0 (Все слова)';
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Migration: Remove UNIQUE constraint from words.name
|
||||||
|
-- This script removes the unique constraint on the name column in the words table
|
||||||
|
|
||||||
|
-- Drop the unique constraint on words.name if it exists
|
||||||
|
ALTER TABLE words
|
||||||
|
DROP CONSTRAINT IF EXISTS words_name_key;
|
||||||
|
|
||||||
|
-- Also try to drop constraint if it was created with different name
|
||||||
|
ALTER TABLE words
|
||||||
|
DROP CONSTRAINT IF EXISTS words_name_unique;
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Migration: Add config_dictionaries table (many-to-many relationship)
|
||||||
|
-- This script creates the config_dictionaries table linking configs and dictionaries
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: config_dictionaries
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS config_dictionaries (
|
||||||
|
config_id INTEGER NOT NULL REFERENCES configs(id) ON DELETE CASCADE,
|
||||||
|
dictionary_id INTEGER NOT NULL REFERENCES dictionaries(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (config_id, dictionary_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for better query performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_config_dictionaries_config_id ON config_dictionaries(config_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_config_dictionaries_dictionary_id ON config_dictionaries(dictionary_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE config_dictionaries IS 'Many-to-many relationship table linking configs and dictionaries. If no dictionaries are selected for a config, all dictionaries will be used.';
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- Migration: Fix weekly_report_mv to use ISOYEAR instead of YEAR
|
||||||
|
-- This fixes incorrect week calculations at year boundaries
|
||||||
|
-- Date: 2024
|
||||||
|
|
||||||
|
-- Drop existing materialized view
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
|
||||||
|
|
||||||
|
-- Recreate materialized view with ISOYEAR
|
||||||
|
CREATE MATERIALIZED VIEW weekly_report_mv AS
|
||||||
|
SELECT
|
||||||
|
n.project_id,
|
||||||
|
-- 🔑 ГЛАВНОЕ ИСПРАВЛЕНИЕ: Используем ISOYEAR
|
||||||
|
EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year,
|
||||||
|
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
|
||||||
|
SUM(n.score) AS total_score
|
||||||
|
FROM
|
||||||
|
nodes n
|
||||||
|
JOIN
|
||||||
|
entries e ON n.entry_id = e.id
|
||||||
|
GROUP BY
|
||||||
|
1, 2, 3
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
-- Recreate index
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week
|
||||||
|
ON weekly_report_mv(project_id, report_year, report_week);
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations';
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- Migration: Fix weekly_report_mv structure to include all projects via LEFT JOIN
|
||||||
|
-- This ensures the view structure matches the code in main.go
|
||||||
|
-- Date: 2024-12-29
|
||||||
|
--
|
||||||
|
-- Issue: Migration 005 created the view without LEFT JOIN to projects table,
|
||||||
|
-- which means projects without data were not included in the view.
|
||||||
|
-- This migration fixes the structure to match main.go implementation.
|
||||||
|
|
||||||
|
-- Drop existing materialized view
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv;
|
||||||
|
|
||||||
|
-- Recreate materialized view with correct structure (LEFT JOIN with projects)
|
||||||
|
-- This ensures all projects are included, even if they have no data for a given week
|
||||||
|
CREATE MATERIALIZED VIEW weekly_report_mv AS
|
||||||
|
SELECT
|
||||||
|
p.id AS project_id,
|
||||||
|
agg.report_year,
|
||||||
|
agg.report_week,
|
||||||
|
COALESCE(agg.total_score, 0.0000) AS total_score
|
||||||
|
FROM
|
||||||
|
projects p
|
||||||
|
LEFT JOIN
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
n.project_id,
|
||||||
|
EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year,
|
||||||
|
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
|
||||||
|
SUM(n.score) AS total_score
|
||||||
|
FROM
|
||||||
|
nodes n
|
||||||
|
JOIN
|
||||||
|
entries e ON n.entry_id = e.id
|
||||||
|
GROUP BY
|
||||||
|
1, 2, 3
|
||||||
|
) agg
|
||||||
|
ON p.id = agg.project_id
|
||||||
|
WHERE
|
||||||
|
p.deleted = FALSE
|
||||||
|
ORDER BY
|
||||||
|
p.id, agg.report_year, agg.report_week
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
-- Recreate index
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week
|
||||||
|
ON weekly_report_mv(project_id, report_year, report_week);
|
||||||
|
|
||||||
|
COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN.';
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Migration: Add deleted field to projects table
|
||||||
|
-- This script adds a deleted boolean field to mark projects as deleted (soft delete)
|
||||||
|
|
||||||
|
-- Add deleted column to projects table
|
||||||
|
ALTER TABLE projects
|
||||||
|
ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- Create index on deleted column for better query performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_projects_deleted ON projects(deleted);
|
||||||
|
|
||||||
|
-- Add comment for documentation
|
||||||
|
COMMENT ON COLUMN projects.deleted IS 'Soft delete flag: TRUE if project is deleted, FALSE otherwise';
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration: Add telegram_integrations table
|
||||||
|
-- This script creates a table to store Telegram bot tokens and chat IDs
|
||||||
|
|
||||||
|
-- Create telegram_integrations table
|
||||||
|
CREATE TABLE IF NOT EXISTS telegram_integrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
chat_id VARCHAR(255),
|
||||||
|
bot_token VARCHAR(255)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add comment for documentation
|
||||||
|
COMMENT ON TABLE telegram_integrations IS 'Stores Telegram bot tokens and chat IDs for integrations';
|
||||||
|
COMMENT ON COLUMN telegram_integrations.id IS 'Auto-increment primary key';
|
||||||
|
COMMENT ON COLUMN telegram_integrations.chat_id IS 'Telegram chat ID (nullable, set automatically after first message)';
|
||||||
|
COMMENT ON COLUMN telegram_integrations.bot_token IS 'Telegram bot token (nullable, set by user)';
|
||||||
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
-- Migration: Add users table and user_id to all tables for multi-tenancy
|
||||||
|
-- This script adds user authentication and makes all data user-specific
|
||||||
|
-- All statements use IF NOT EXISTS / IF EXISTS for idempotency
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: users
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255),
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
last_login_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: refresh_tokens
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash VARCHAR(255) NOT NULL,
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to projects
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE projects
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_projects_user_id ON projects(user_id);
|
||||||
|
|
||||||
|
-- Drop old unique constraint (name now unique per user, handled in app)
|
||||||
|
ALTER TABLE projects DROP CONSTRAINT IF EXISTS unique_project_name;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to entries
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE entries
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entries_user_id ON entries(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to dictionaries
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE dictionaries
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dictionaries_user_id ON dictionaries(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to words
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE words
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_words_user_id ON words(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to progress
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE progress
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_progress_user_id ON progress(user_id);
|
||||||
|
|
||||||
|
-- Drop old unique constraint (word_id now unique per user)
|
||||||
|
ALTER TABLE progress DROP CONSTRAINT IF EXISTS progress_word_id_key;
|
||||||
|
|
||||||
|
-- Create new unique constraint per user
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_progress_word_user_unique ON progress(word_id, user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to configs
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE configs
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_configs_user_id ON configs(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to telegram_integrations
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telegram_integrations_user_id ON telegram_integrations(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to weekly_goals
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE weekly_goals
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weekly_goals_user_id ON weekly_goals(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add user_id to nodes (score data)
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE nodes
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nodes_user_id ON nodes(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE users IS 'Users table for authentication and multi-tenancy';
|
||||||
|
COMMENT ON COLUMN users.email IS 'User email address (unique, used for login)';
|
||||||
|
COMMENT ON COLUMN users.password_hash IS 'Bcrypt hashed password';
|
||||||
|
COMMENT ON COLUMN users.name IS 'User display name';
|
||||||
|
COMMENT ON COLUMN users.is_active IS 'Whether the user account is active';
|
||||||
|
COMMENT ON TABLE refresh_tokens IS 'JWT refresh tokens for persistent login';
|
||||||
|
|
||||||
|
-- Note: The first user who logs in will automatically become the owner of all
|
||||||
|
-- existing data (projects, entries, dictionaries, words, etc.) that have NULL user_id.
|
||||||
|
-- This is handled in the application code (claimOrphanedData function).
|
||||||
17
play-life-backend/migrations_old/011_add_webhook_tokens.sql
Normal file
17
play-life-backend/migrations_old/011_add_webhook_tokens.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- Migration: Add webhook_token to telegram_integrations
|
||||||
|
-- This allows identifying user by webhook URL token
|
||||||
|
|
||||||
|
-- Add webhook_token column to telegram_integrations
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS webhook_token VARCHAR(255);
|
||||||
|
|
||||||
|
-- Create unique index on webhook_token for fast lookups
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_webhook_token
|
||||||
|
ON telegram_integrations(webhook_token)
|
||||||
|
WHERE webhook_token IS NOT NULL;
|
||||||
|
|
||||||
|
-- Generate webhook tokens for existing integrations
|
||||||
|
-- This will be handled by application code, but we ensure the column exists
|
||||||
|
|
||||||
|
COMMENT ON COLUMN telegram_integrations.webhook_token IS 'Unique token for webhook URL identification (e.g., /webhook/telegram/{token})';
|
||||||
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
-- Migration: Refactor telegram_integrations for single shared bot
|
||||||
|
-- and move Todoist webhook_token to separate table
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 1. Создаем таблицу todoist_integrations
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS todoist_integrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
webhook_token VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT todoist_integrations_user_id_unique UNIQUE (user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_webhook_token
|
||||||
|
ON todoist_integrations(webhook_token);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_todoist_integrations_user_id
|
||||||
|
ON todoist_integrations(user_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE todoist_integrations IS 'Todoist webhook integration settings per user';
|
||||||
|
COMMENT ON COLUMN todoist_integrations.webhook_token IS 'Unique token for Todoist webhook URL';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 2. Мигрируем webhook_token из telegram_integrations в todoist_integrations
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO todoist_integrations (user_id, webhook_token, created_at, updated_at)
|
||||||
|
SELECT user_id, webhook_token, COALESCE(created_at, CURRENT_TIMESTAMP), CURRENT_TIMESTAMP
|
||||||
|
FROM telegram_integrations
|
||||||
|
WHERE webhook_token IS NOT NULL
|
||||||
|
AND webhook_token != ''
|
||||||
|
AND user_id IS NOT NULL
|
||||||
|
ON CONFLICT (user_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 3. Модифицируем telegram_integrations
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Удаляем bot_token (будет в .env)
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
DROP COLUMN IF EXISTS bot_token;
|
||||||
|
|
||||||
|
-- Удаляем webhook_token (перенесли в todoist_integrations)
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
DROP COLUMN IF EXISTS webhook_token;
|
||||||
|
|
||||||
|
-- Добавляем telegram_user_id
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS telegram_user_id BIGINT;
|
||||||
|
|
||||||
|
-- Добавляем start_token для deep links
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS start_token VARCHAR(255);
|
||||||
|
|
||||||
|
-- Добавляем timestamps если их нет
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
ALTER TABLE telegram_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 4. Создаем индексы
|
||||||
|
-- ============================================
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_start_token
|
||||||
|
ON telegram_integrations(start_token)
|
||||||
|
WHERE start_token IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_telegram_user_id
|
||||||
|
ON telegram_integrations(telegram_user_id)
|
||||||
|
WHERE telegram_user_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Уникальность user_id
|
||||||
|
DROP INDEX IF EXISTS idx_telegram_integrations_user_id;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_user_id_unique
|
||||||
|
ON telegram_integrations(user_id)
|
||||||
|
WHERE user_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Индекс для поиска по chat_id
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telegram_integrations_chat_id
|
||||||
|
ON telegram_integrations(chat_id)
|
||||||
|
WHERE chat_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Удаляем старый индекс webhook_token
|
||||||
|
DROP INDEX IF EXISTS idx_telegram_integrations_webhook_token;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 5. Очищаем данные Telegram для переподключения
|
||||||
|
-- ============================================
|
||||||
|
UPDATE telegram_integrations
|
||||||
|
SET chat_id = NULL,
|
||||||
|
telegram_user_id = NULL,
|
||||||
|
start_token = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Комментарии
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON COLUMN telegram_integrations.telegram_user_id IS 'Telegram user ID (message.from.id)';
|
||||||
|
COMMENT ON COLUMN telegram_integrations.chat_id IS 'Telegram chat ID для отправки сообщений';
|
||||||
|
COMMENT ON COLUMN telegram_integrations.start_token IS 'Временный токен для deep link при первом подключении';
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- Migration: Refactor todoist_integrations for single Todoist app
|
||||||
|
-- Webhook теперь единый для всего приложения, токены в URL больше не нужны
|
||||||
|
-- Все пользователи используют одно Todoist приложение
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 1. Добавляем новые поля
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS todoist_user_id BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS todoist_email VARCHAR(255);
|
||||||
|
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
ADD COLUMN IF NOT EXISTS access_token TEXT;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 2. Удаляем webhook_token (больше не нужен!)
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE todoist_integrations
|
||||||
|
DROP COLUMN IF EXISTS webhook_token;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 3. Удаляем старый индекс на webhook_token
|
||||||
|
-- ============================================
|
||||||
|
DROP INDEX IF EXISTS idx_todoist_integrations_webhook_token;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 4. Создаем новые индексы
|
||||||
|
-- ============================================
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_user_id
|
||||||
|
ON todoist_integrations(todoist_user_id)
|
||||||
|
WHERE todoist_user_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_email
|
||||||
|
ON todoist_integrations(todoist_email)
|
||||||
|
WHERE todoist_email IS NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 5. Комментарии
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON COLUMN todoist_integrations.todoist_user_id IS 'Todoist user ID (from OAuth) - used to identify user in webhooks';
|
||||||
|
COMMENT ON COLUMN todoist_integrations.todoist_email IS 'Todoist user email (from OAuth)';
|
||||||
|
COMMENT ON COLUMN todoist_integrations.access_token IS 'Todoist OAuth access token (permanent)';
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Migration: Make refresh tokens permanent (no expiration)
|
||||||
|
-- Refresh tokens теперь не имеют срока действия (expires_at может быть NULL)
|
||||||
|
-- Access tokens живут 24 часа вместо 15 минут
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 1. Изменяем expires_at на NULLABLE
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE refresh_tokens
|
||||||
|
ALTER COLUMN expires_at DROP NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 2. Устанавливаем NULL для всех существующих токенов
|
||||||
|
-- (или можно оставить их как есть, если они еще не истекли)
|
||||||
|
-- ============================================
|
||||||
|
-- UPDATE refresh_tokens SET expires_at = NULL WHERE expires_at > NOW();
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 3. Комментарий
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON COLUMN refresh_tokens.expires_at IS 'Expiration date for refresh token. NULL means token never expires.';
|
||||||
|
|
||||||
58
play-life-backend/migrations_old/015_add_tasks.sql
Normal file
58
play-life-backend/migrations_old/015_add_tasks.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
-- Migration: Add tasks and reward_configs tables
|
||||||
|
-- This script creates tables for task management system
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: tasks
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
completed INTEGER DEFAULT 0,
|
||||||
|
last_completed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
reward_message TEXT,
|
||||||
|
progression_base NUMERIC(10,4),
|
||||||
|
deleted BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_parent_task_id ON tasks(parent_task_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_deleted ON tasks(deleted);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_last_completed_at ON tasks(last_completed_at);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: reward_configs
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS reward_configs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
position INTEGER NOT NULL,
|
||||||
|
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
value NUMERIC(10,4) NOT NULL,
|
||||||
|
use_progression BOOLEAN DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reward_configs_task_id ON reward_configs(task_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reward_configs_project_id ON reward_configs(project_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_reward_configs_task_position ON reward_configs(task_id, position);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE tasks IS 'Tasks table for task management system';
|
||||||
|
COMMENT ON COLUMN tasks.name IS 'Task name (required for main tasks, optional for subtasks)';
|
||||||
|
COMMENT ON COLUMN tasks.completed IS 'Number of times task was completed';
|
||||||
|
COMMENT ON COLUMN tasks.last_completed_at IS 'Date and time of last task completion';
|
||||||
|
COMMENT ON COLUMN tasks.parent_task_id IS 'Parent task ID for subtasks (NULL for main tasks)';
|
||||||
|
COMMENT ON COLUMN tasks.reward_message IS 'Reward message template with placeholders ${0}, ${1}, etc.';
|
||||||
|
COMMENT ON COLUMN tasks.progression_base IS 'Base value for progression calculation (NULL means no progression)';
|
||||||
|
COMMENT ON COLUMN tasks.deleted IS 'Soft delete flag';
|
||||||
|
|
||||||
|
COMMENT ON TABLE reward_configs IS 'Reward configurations for tasks';
|
||||||
|
COMMENT ON COLUMN reward_configs.position IS 'Position in reward_message template (0, 1, 2, etc.)';
|
||||||
|
COMMENT ON COLUMN reward_configs.task_id IS 'Task this reward belongs to';
|
||||||
|
COMMENT ON COLUMN reward_configs.project_id IS 'Project to add reward to';
|
||||||
|
COMMENT ON COLUMN reward_configs.value IS 'Default score value (can be negative)';
|
||||||
|
COMMENT ON COLUMN reward_configs.use_progression IS 'Whether to use progression multiplier for this reward';
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Migration: Add repetition_period field to tasks table
|
||||||
|
-- This script adds the repetition_period field for recurring tasks
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add repetition_period column
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS repetition_period INTERVAL;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON COLUMN tasks.repetition_period IS 'Period after which task should be repeated (NULL means task is not recurring)';
|
||||||
|
|
||||||
14
play-life-backend/migrations_old/017_add_next_show_at.sql
Normal file
14
play-life-backend/migrations_old/017_add_next_show_at.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- Migration: Add next_show_at field to tasks table
|
||||||
|
-- This script adds the next_show_at field for postponing tasks
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add next_show_at column
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS next_show_at TIMESTAMP WITH TIME ZONE;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON COLUMN tasks.next_show_at IS 'Date when task should be shown again (NULL means use last_completed_at + period)';
|
||||||
|
|
||||||
16
play-life-backend/migrations_old/018_add_repetition_date.sql
Normal file
16
play-life-backend/migrations_old/018_add_repetition_date.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration: Add repetition_date field to tasks table
|
||||||
|
-- This script adds the repetition_date field for pattern-based recurring tasks
|
||||||
|
-- Format examples: "2 week" (2nd day of week), "15 month" (15th day of month), "02-01 year" (Feb 1st)
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Add repetition_date column
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS repetition_date TEXT;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON COLUMN tasks.repetition_date IS 'Pattern-based repetition: "N week" (day of week 1-7), "N month" (day of month 1-31), "MM-DD year" (specific date). Mutually exclusive with repetition_period.';
|
||||||
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user