Compare commits
267 Commits
v1.1.0
...
405d30bead
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
405d30bead | ||
|
|
d355928aa9 | ||
|
|
af2aaa4168 | ||
|
|
826996c5cd | ||
|
|
dfccba4e55 | ||
|
|
f1c590de43 | ||
|
|
9f37d8b518 | ||
|
|
0275d9aecf | ||
|
|
3ce408a6b1 | ||
|
|
e89f0879c6 | ||
|
|
73ce74bc7c | ||
|
|
867e8803bd | ||
|
|
49eff37399 | ||
|
|
8a036df1b4 | ||
|
|
65f21cd025 | ||
|
|
a76d1d40cb | ||
|
|
6e9e2db23e | ||
|
|
d6d40f4f86 | ||
|
|
9c814d62b2 | ||
|
|
9a066c88ac | ||
|
|
22f6807eb2 | ||
|
|
59d376b999 | ||
|
|
0463c237c0 | ||
|
|
0ee689151e | ||
|
|
126f9ec919 | ||
|
|
736f08887a | ||
|
|
106defc3af | ||
|
|
42cf825de1 | ||
|
|
a60bfe97dc | ||
|
|
09ab87b6dd | ||
|
|
f5e10c143f | ||
|
|
8965e43341 | ||
|
|
62d36dca17 | ||
|
|
e3e9084792 | ||
|
|
f1ee6082dd | ||
|
|
479ffb2483 | ||
|
|
c22e56e68a | ||
|
|
b9482dc86d | ||
|
|
e66a3cecce | ||
|
|
8023319ee4 | ||
|
|
794947ea89 | ||
|
|
cdd10d50c0 | ||
|
|
43df4d76ce | ||
|
|
a169da9387 | ||
|
|
e0ffefc904 | ||
|
|
df3cced995 | ||
|
|
36dd96976f | ||
|
|
c3d366b9c2 | ||
|
|
0c5f7fa9d9 | ||
|
|
23184f4b66 | ||
|
|
b65dc30a9b | ||
|
|
0162db46b3 | ||
|
|
6b95326a86 | ||
|
|
56da114210 | ||
|
|
d90df473a2 | ||
|
|
78ef1e78dc | ||
|
|
c3d2c0d6a6 | ||
|
|
ff9fec7d7a | ||
|
|
56e29230ff | ||
|
|
ebe71f073c | ||
|
|
8ffbfc6afd | ||
|
|
f34d35febf | ||
|
|
fc7464021e | ||
|
|
f7d340fc70 | ||
|
|
763b13358e | ||
|
|
de29e3f602 | ||
|
|
a780b46175 | ||
|
|
3278eef2c5 | ||
|
|
5ac3c931b9 | ||
|
|
89e66d6093 | ||
|
|
b15e1dd615 | ||
|
|
dfe9f5b9a0 | ||
|
|
2428ca5fd0 | ||
|
|
e955494dc8 | ||
|
|
25f0c2697a | ||
|
|
56d413f761 | ||
|
|
f266508d04 | ||
|
|
5c5fc07481 | ||
|
|
4e270cb322 | ||
|
|
ba0f34c91b | ||
|
|
a886cf13e8 | ||
|
|
8e29acd25e | ||
|
|
6dbb0f8d90 | ||
|
|
6174475509 | ||
|
|
a611f05959 | ||
|
|
904b00f3f5 | ||
|
|
41aed56689 | ||
|
|
6bea094b7f | ||
|
|
99156d578a | ||
|
|
08cc20265e | ||
|
|
ac0d67c2cf | ||
|
|
daccb81763 | ||
|
|
90643c504a | ||
|
|
b8ef59bfd1 | ||
|
|
34f162576b | ||
|
|
fe3721a56f | ||
|
|
ef1d6fb59a | ||
|
|
47f47608bc | ||
|
|
dd4fa39d01 | ||
|
|
834e600886 | ||
|
|
d1197e9428 | ||
|
|
6fceafaa67 | ||
|
|
d569960ec1 | ||
|
|
5a7c8b5d2f | ||
|
|
e823312f0e | ||
|
|
e7ef6caa41 | ||
|
|
5c1a584925 | ||
|
|
fa231c2215 | ||
|
|
0adf81cf6a | ||
|
|
6578db6ec4 | ||
|
|
d0d1cbd8cb | ||
|
|
068794a98c | ||
|
|
24be9fad27 | ||
|
|
e41f721106 | ||
|
|
2626722af9 | ||
|
|
b2c95dcbab | ||
|
|
5f2d610deb | ||
|
|
eb708b057d | ||
|
|
08d86eb5f5 | ||
|
|
2369661015 | ||
|
|
ae5c824592 | ||
|
|
c4b4ed2c3f | ||
|
|
5905711f7f | ||
|
|
efded0bcd2 | ||
|
|
f884bd3339 | ||
|
|
9c97241d8d | ||
|
|
007a3ae00b | ||
|
|
6cbb646a55 | ||
|
|
939c9ddc26 | ||
|
|
e132aaf79b | ||
|
|
9299af7bad | ||
|
|
50c53c8b63 | ||
|
|
70e4b53f21 | ||
|
|
1188c899da | ||
|
|
ae51060f97 | ||
|
|
6bfb67544e | ||
|
|
678ec6b422 | ||
|
|
84efb42c54 | ||
|
|
53f7edddad | ||
|
|
a19b16a191 | ||
|
|
17ea99837c | ||
|
|
db712a4df6 | ||
|
|
a5452c42f9 | ||
|
|
e77338fc76 | ||
|
|
6eccbbb469 | ||
|
|
40a79eb8db | ||
|
|
629b65144a | ||
|
|
6ce4bf44c9 | ||
|
|
d390fa4825 | ||
|
|
be2ae80226 | ||
|
|
cd61fe4766 | ||
|
|
bea0e17133 | ||
|
|
ab9022a585 | ||
|
|
6d468d6967 | ||
|
|
e3c81a36de | ||
|
|
c654a01116 | ||
|
|
7200cdfda9 | ||
|
|
0e509dd61a | ||
|
|
1f423e1ed3 | ||
|
|
f13838d91a | ||
|
|
f9928c6470 | ||
|
|
5ebb55510e | ||
|
|
81e8ebdf66 | ||
|
|
ce7e0e584a | ||
|
|
db3b2640a8 | ||
|
|
cfd9339e48 | ||
|
|
0783602fe8 | ||
|
|
22995b654d | ||
|
|
b8ae0bb17a | ||
|
|
441f872f33 | ||
|
|
0e53dfbdf7 | ||
|
|
a54c9983d4 | ||
|
|
22bafd8c28 | ||
|
|
f56278c670 | ||
|
|
72a6a3caf9 | ||
|
|
9fbe2081ed | ||
|
|
705eb2400e | ||
|
|
3cf3cd4edb | ||
|
|
b3a83e1e8f | ||
|
|
d368929a4a | ||
|
|
f19ed9cb81 | ||
|
|
e2059ef555 | ||
|
|
932dba8682 | ||
|
|
8023fb9108 | ||
|
|
08f81887b0 | ||
|
|
64d192796c | ||
|
|
f3a7d1c503 | ||
|
|
29cf05a3c3 | ||
|
|
a8cb7c2730 | ||
|
|
374d03cdfd | ||
|
|
d5e4699bcf | ||
|
|
5ccb214c04 | ||
|
|
11e0d0074c | ||
|
|
dde8858d7d | ||
|
|
cc7c6a905e | ||
|
|
3d3fa13f41 | ||
|
|
cbdcecea45 | ||
|
|
6cf4be65b2 | ||
|
|
ef59781633 | ||
|
|
97c031eda4 | ||
|
|
1097a84d06 | ||
|
|
b57b0bc901 | ||
|
|
60a6f4deb4 | ||
|
|
b1cfea22e6 | ||
|
|
2f16876185 | ||
|
|
b9133f60dc | ||
|
|
db74626068 | ||
|
|
b41f6e7cdc | ||
|
|
508355dcb3 | ||
|
|
1da35aaea4 | ||
|
|
d9db42a598 | ||
|
|
28a45ab81e | ||
|
|
9e5790f70e | ||
|
|
7df258da15 | ||
|
|
0ea531889d | ||
|
|
28d8148665 | ||
|
|
a7bc912db3 | ||
|
|
647c549ec9 | ||
|
|
a6065d7ff1 | ||
|
|
79430ba7f0 | ||
|
|
6d7d59d2ae | ||
|
|
2b9a024d3e | ||
|
|
4767f5975c | ||
|
|
bacb605a0c | ||
|
|
3bdad682b3 | ||
|
|
01e8b3468c | ||
|
|
ac34f480be | ||
|
|
27befeb92b | ||
|
|
9e50a718d8 | ||
|
|
08c5422d35 | ||
|
|
bf539c6e91 | ||
|
|
2326a774ad | ||
|
|
1cfaaa9506 | ||
|
|
ecc61c2a5f | ||
|
|
a5ce0de236 | ||
|
|
ccb365c95c | ||
|
|
1b2c79a8f2 | ||
|
|
d012f39be8 | ||
|
|
8b66e5fd6e | ||
|
|
4ca6eb4fd5 | ||
|
|
3a256dc290 | ||
|
|
38f640e38e | ||
|
|
8f7acee60c | ||
|
|
bcea4b2bf5 | ||
|
|
bd6dfd968c | ||
|
|
7547058507 | ||
|
|
53e3f23422 | ||
|
|
713f6020f6 | ||
|
|
72002a2b4f | ||
|
|
bc73160e1a | ||
|
|
9206b73b33 | ||
|
|
e74c4cf599 | ||
|
|
a7128703fe | ||
|
|
8ba7e8fd45 | ||
|
|
4df054536a | ||
|
|
cf4d5d40c3 | ||
|
|
d96bb2ce8d | ||
|
|
6f77f0643c | ||
|
|
edc29fbd97 | ||
|
|
7704de334c | ||
|
|
ad1caceda0 | ||
|
|
91d9b52524 | ||
|
|
914998980e | ||
|
|
b709192447 | ||
|
|
4a06ceb7f6 | ||
|
|
6015b62d29 | ||
|
|
f8aa81f963 |
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 версию
|
||||||
|
- "Подними версию и запушь" → спросить какой тип версии поднять
|
||||||
|
- "Запуш именения" → запушить без изменения версии
|
||||||
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
|
||||||
@@ -22,6 +22,10 @@ jobs:
|
|||||||
CUR=$(cat VERSION | tr -d '[:space:]')
|
CUR=$(cat VERSION | tr -d '[:space:]')
|
||||||
echo "current=$CUR" >> $GITHUB_OUTPUT
|
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")
|
PREV=$(git show HEAD~1:VERSION 2>/dev/null | tr -d '[:space:]' || echo "none")
|
||||||
|
|
||||||
@@ -32,25 +36,180 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Patch DNS for Local Network
|
- name: Patch DNS for Local Network
|
||||||
if: steps.version_check.outputs.changed == 'true'
|
|
||||||
run: |
|
run: |
|
||||||
# Записываем IP Synology прямо в контейнер сборки
|
# Записываем IP Synology прямо в контейнер сборки
|
||||||
echo "192.168.50.55 dungeonsiege.synology.me" | sudo tee -a /etc/hosts
|
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
|
- name: Log in to Gitea Registry
|
||||||
if: steps.version_check.outputs.changed == 'true'
|
if: steps.version_check.outputs.changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.GIT_TOKEN }}" | docker login dungeonsiege.synology.me -u ${{ secrets.GIT_USERNAME }} --password-stdin
|
echo "${{ secrets.GIT_TOKEN }}" | docker login dungeonsiege.synology.me -u ${{ secrets.GIT_USERNAME }} --password-stdin
|
||||||
|
|
||||||
- name: Build and Push
|
- name: Push Docker Image
|
||||||
|
id: push
|
||||||
if: steps.version_check.outputs.changed == 'true'
|
if: steps.version_check.outputs.changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
REGISTRY="dungeonsiege.synology.me/poignatov/play-life"
|
REGISTRY="dungeonsiege.synology.me/poignatov/play-life"
|
||||||
VER="${{ steps.version_check.outputs.current }}"
|
VER="${{ steps.version_check.outputs.current }}"
|
||||||
|
|
||||||
# Собираем один раз
|
# Тегируем образ версией
|
||||||
docker build -t $REGISTRY:latest -t $REGISTRY:$VER .
|
docker tag $REGISTRY:latest $REGISTRY:$VER
|
||||||
|
|
||||||
# Пушим оба тега
|
# Пушим оба тега
|
||||||
|
echo "Pushing image to registry..."
|
||||||
docker push $REGISTRY:latest
|
docker push $REGISTRY:latest
|
||||||
docker push $REGISTRY:$VER
|
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: ⏭️
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,3 +11,6 @@ node_modules/
|
|||||||
database-dumps/*.sql
|
database-dumps/*.sql
|
||||||
database-dumps/*.sql.gz
|
database-dumps/*.sql.gz
|
||||||
!database-dumps/.gitkeep
|
!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": "Восстановление базы данных из самого свежего дампа в локальную базу (автоматически выбирает последний дамп)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
13
Dockerfile
13
Dockerfile
@@ -10,8 +10,11 @@ COPY play-life-web/ .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Build Backend
|
# Stage 2: Build Backend
|
||||||
FROM golang:1.21-alpine AS backend-builder
|
FROM golang:1.24-alpine AS backend-builder
|
||||||
WORKDIR /app/backend
|
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 ./
|
COPY play-life-backend/go.mod play-life-backend/go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY play-life-backend/ .
|
COPY play-life-backend/ .
|
||||||
@@ -33,12 +36,20 @@ RUN apk --no-cache add \
|
|||||||
# Создаем директории
|
# Создаем директории
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Создаем директорию для загруженных файлов
|
||||||
|
RUN mkdir -p /app/uploads/wishlist && \
|
||||||
|
chmod 755 /app/uploads
|
||||||
|
|
||||||
# Копируем собранный frontend
|
# Копируем собранный frontend
|
||||||
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
|
COPY --from=frontend-builder /app/frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# Копируем собранный backend
|
# Копируем собранный backend
|
||||||
COPY --from=backend-builder /app/backend/main /app/backend/main
|
COPY --from=backend-builder /app/backend/main /app/backend/main
|
||||||
COPY play-life-backend/admin.html /app/backend/admin.html
|
COPY play-life-backend/admin.html /app/backend/admin.html
|
||||||
|
# Копируем миграции для применения при запуске
|
||||||
|
COPY play-life-backend/migrations /migrations
|
||||||
|
# Копируем файл версии
|
||||||
|
COPY VERSION /app/VERSION
|
||||||
|
|
||||||
# Копируем конфигурацию nginx
|
# Копируем конфигурацию nginx
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
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"
|
||||||
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
|
||||||
@@ -14,6 +14,8 @@ services:
|
|||||||
POSTGRES_DB: ${DB_NAME:-playeng}
|
POSTGRES_DB: ${DB_NAME:-playeng}
|
||||||
ports:
|
ports:
|
||||||
- "${DB_PORT:-5432}:5432"
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -41,6 +43,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
- ./play-life-backend/migrations:/migrations
|
- ./play-life-backend/migrations:/migrations
|
||||||
|
- ./uploads:/app/uploads
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|
||||||
@@ -58,6 +61,10 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
name: play-life_postgres_data
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: play-life-network
|
name: play-life-network
|
||||||
|
|||||||
67
dump-db.sh
67
dump-db.sh
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
# Скрипт для создания дампа базы данных
|
# Скрипт для создания дампа базы данных
|
||||||
# Использование:
|
# Использование:
|
||||||
# ./dump-db.sh [имя_дампа] # Дамп из .env
|
# ./dump-db.sh [имя_дампа] # Дамп из .env.prod
|
||||||
# ./dump-db.sh --env-file .env.prod [имя] # Дамп из указанного файла
|
# ./dump-db.sh --env-file .env [имя] # Дамп из указанного файла
|
||||||
# ./dump-db.sh production-backup # Именованный дамп из .env
|
# ./dump-db.sh production-backup # Именованный дамп из .env.prod
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Значения по умолчанию
|
# Значения по умолчанию
|
||||||
DEFAULT_ENV_FILE=".env"
|
DEFAULT_ENV_FILE=".env.prod"
|
||||||
ENV_FILE="$DEFAULT_ENV_FILE"
|
ENV_FILE="$DEFAULT_ENV_FILE"
|
||||||
DUMP_NAME=""
|
DUMP_NAME=""
|
||||||
|
|
||||||
@@ -65,27 +65,46 @@ echo " Хост: $DB_HOST:$DB_PORT"
|
|||||||
echo " Пользователь: $DB_USER"
|
echo " Пользователь: $DB_USER"
|
||||||
echo " Файл: $DUMP_PATH"
|
echo " Файл: $DUMP_PATH"
|
||||||
|
|
||||||
# Создаем дамп через docker-compose, если контейнер запущен
|
# Создаем дамп через docker-compose, если контейнер запущен И хост локальный
|
||||||
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
|
if [ "$DB_HOST" = "localhost" ] || [ "$DB_HOST" = "127.0.0.1" ] || [ -z "$DB_HOST" ]; then
|
||||||
echo " Используется docker-compose..."
|
if docker-compose ps db 2>/dev/null | grep -q "Up"; then
|
||||||
docker-compose exec -T db pg_dump -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
echo " Используется docker-compose..."
|
||||||
elif command -v pg_dump &> /dev/null; then
|
docker-compose exec -T db pg_dump -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
||||||
# Или напрямую через pg_dump, если БД доступна локально
|
elif command -v pg_dump &> /dev/null; then
|
||||||
echo " Используется локальный pg_dump..."
|
# Или напрямую через pg_dump, если БД доступна локально
|
||||||
PGPASSWORD="$DB_PASSWORD" pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
echo " Используется локальный pg_dump..."
|
||||||
elif command -v docker &> /dev/null; then
|
PGPASSWORD="$DB_PASSWORD" pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
||||||
# Используем Docker образ postgres для создания дампа
|
elif command -v docker &> /dev/null; then
|
||||||
# Используем latest для совместимости с разными версиями сервера
|
# Используем Docker образ postgres для создания дампа
|
||||||
echo " Используется Docker (postgres:latest)..."
|
echo " Используется Docker (postgres:latest)..."
|
||||||
# Используем --network host для доступа к удаленным хостам
|
docker run --rm -i --network host \
|
||||||
docker run --rm -i --network host \
|
-e PGPASSWORD="$DB_PASSWORD" \
|
||||||
-e PGPASSWORD="$DB_PASSWORD" \
|
postgres:latest \
|
||||||
postgres:latest \
|
pg_dump -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" > "$DUMP_PATH"
|
||||||
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
|
else
|
||||||
echo "❌ Ошибка: pg_dump не найден, docker-compose не запущен и Docker недоступен"
|
# Для удаленных хостов используем pg_dump или Docker
|
||||||
echo " Установите PostgreSQL клиент или Docker"
|
if command -v pg_dump &> /dev/null; then
|
||||||
exit 1
|
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
|
fi
|
||||||
|
|
||||||
# Сжимаем дамп
|
# Сжимаем дамп
|
||||||
|
|||||||
56
env.example
56
env.example
@@ -28,8 +28,10 @@ WEB_PORT=3001
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Telegram Bot Configuration
|
# Telegram Bot Configuration
|
||||||
# ============================================
|
# ============================================
|
||||||
# Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram"
|
# Токен единого бота для всех пользователей
|
||||||
# Get token from @BotFather in Telegram: https://t.me/botfather
|
# Получить у @BotFather: https://t.me/botfather
|
||||||
|
TELEGRAM_BOT_TOKEN=your-bot-token-here
|
||||||
|
|
||||||
# Base URL для автоматической настройки webhook
|
# Base URL для автоматической настройки webhook
|
||||||
# Примеры:
|
# Примеры:
|
||||||
# - Для production с HTTPS: https://your-domain.com
|
# - Для production с HTTPS: https://your-domain.com
|
||||||
@@ -40,13 +42,55 @@ WEB_PORT=3001
|
|||||||
WEBHOOK_BASE_URL=https://your-domain.com
|
WEBHOOK_BASE_URL=https://your-domain.com
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Todoist Webhook Configuration (optional)
|
# Todoist Integration Configuration
|
||||||
# ============================================
|
# ============================================
|
||||||
# Секрет для проверки подлинности webhook от Todoist
|
# Единое Todoist приложение для всех пользователей Play Life
|
||||||
# Если задан, все запросы должны содержать заголовок X-Todoist-Webhook-Secret с этим значением
|
# Настроить в: 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=
|
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
|
# Scheduler Configuration
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
@@ -49,8 +49,8 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy other API endpoints to backend
|
# Proxy admin panel to backend (must be before location /)
|
||||||
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|project/move|project/delete|message/post|weekly_goals/setup|admin|admin\.html)$ {
|
location ^~ /admin {
|
||||||
proxy_pass http://localhost:8080;
|
proxy_pass http://localhost:8080;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
@@ -62,6 +62,57 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
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)$ {
|
||||||
|
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)
|
# Handle React Router (SPA)
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,8 +10,11 @@ COPY play-life-web/ .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Build Backend
|
# Stage 2: Build Backend
|
||||||
FROM golang:1.21-alpine AS backend-builder
|
FROM golang:1.24-alpine AS backend-builder
|
||||||
WORKDIR /app/backend
|
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 ./
|
COPY play-life-backend/go.mod play-life-backend/go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY play-life-backend/ .
|
COPY play-life-backend/ .
|
||||||
@@ -27,7 +30,12 @@ RUN apk --no-cache add \
|
|||||||
nginx \
|
nginx \
|
||||||
supervisor \
|
supervisor \
|
||||||
curl \
|
curl \
|
||||||
tzdata
|
tzdata \
|
||||||
|
chromium \
|
||||||
|
chromium-chromedriver \
|
||||||
|
udev \
|
||||||
|
ttf-freefont \
|
||||||
|
font-noto-emoji
|
||||||
|
|
||||||
# Создаем директории
|
# Создаем директории
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
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 "Проверьте работу приложения"
|
||||||
|
```
|
||||||
@@ -161,43 +161,55 @@
|
|||||||
color: white;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<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>
|
<h1>🎯 Play Life Backend - Admin Panel</h1>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<!-- Message Post Card -->
|
|
||||||
<div class="card">
|
|
||||||
<h2>
|
|
||||||
📨 Message Post
|
|
||||||
<span class="status" id="messageStatus" style="display: none;"></span>
|
|
||||||
</h2>
|
|
||||||
<textarea id="messageText" placeholder="Введите сообщение с паттернами **Project+10.5** или **Project-5.0**...
|
|
||||||
|
|
||||||
Пример:
|
|
||||||
Сегодня работал над проектами:
|
|
||||||
**Frontend+15.5**
|
|
||||||
**Backend+8.0**
|
|
||||||
**Design-2.5**"></textarea>
|
|
||||||
<button onclick="sendMessage()">Отправить сообщение</button>
|
|
||||||
<div id="messageResult"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Daily Report Trigger Card -->
|
|
||||||
<div class="card">
|
|
||||||
<h2>
|
|
||||||
📈 Daily Report Trigger
|
|
||||||
<span class="status" id="dailyReportStatus" style="display: none;"></span>
|
|
||||||
</h2>
|
|
||||||
<p style="margin-bottom: 15px; color: #666;">
|
|
||||||
Нажмите кнопку для отправки ежедневного отчёта по Score и Целям в Telegram (обычно отправляется автоматически в 23:59).
|
|
||||||
</p>
|
|
||||||
<button onclick="triggerDailyReport()">Отправить отчёт</button>
|
|
||||||
<div id="dailyReportResult"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Weekly Goals Setup Card -->
|
<!-- Weekly Goals Setup Card -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>
|
<h2>
|
||||||
@@ -214,12 +226,63 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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() {
|
function getApiUrl() {
|
||||||
// Автоматически определяем URL текущего хоста
|
// Автоматически определяем URL текущего хоста
|
||||||
// Админка обслуживается тем же бекендом, поэтому используем текущий origin
|
// Админка обслуживается тем же бекендом, поэтому используем текущий origin
|
||||||
return window.location.origin;
|
return window.location.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверяем авторизацию при загрузке страницы
|
||||||
|
if (!checkAuth()) {
|
||||||
|
// Страница уже скрыта в checkAuth
|
||||||
|
}
|
||||||
|
|
||||||
function showStatus(elementId, status, text) {
|
function showStatus(elementId, status, text) {
|
||||||
const statusEl = document.getElementById(elementId);
|
const statusEl = document.getElementById(elementId);
|
||||||
statusEl.textContent = text;
|
statusEl.textContent = text;
|
||||||
@@ -254,44 +317,6 @@
|
|||||||
resultEl.appendChild(div);
|
resultEl.appendChild(div);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
|
||||||
const text = document.getElementById('messageText').value.trim();
|
|
||||||
if (!text) {
|
|
||||||
alert('Пожалуйста, введите сообщение');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showStatus('messageStatus', 'loading', 'Отправка...');
|
|
||||||
showResult('messageResult', null, false, true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${getApiUrl()}/message/post`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
body: {
|
|
||||||
text: text
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
showStatus('messageStatus', 'success', 'Успешно');
|
|
||||||
showResult('messageResult', data, false);
|
|
||||||
} else {
|
|
||||||
showStatus('messageStatus', 'error', 'Ошибка');
|
|
||||||
showResult('messageResult', data, true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('messageStatus', 'error', 'Ошибка');
|
|
||||||
showResult('messageResult', { error: error.message }, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupWeeklyGoals() {
|
async function setupWeeklyGoals() {
|
||||||
showStatus('goalsStatus', 'loading', 'Обновление...');
|
showStatus('goalsStatus', 'loading', 'Обновление...');
|
||||||
showResult('goalsResult', null, false, true);
|
showResult('goalsResult', null, false, true);
|
||||||
@@ -299,11 +324,13 @@
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(`${getApiUrl()}/weekly_goals/setup`, {
|
const response = await fetch(`${getApiUrl()}/weekly_goals/setup`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: getAuthHeaders()
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (handleAuthError(response)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -319,39 +346,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function triggerDailyReport() {
|
|
||||||
showStatus('dailyReportStatus', 'loading', 'Отправка...');
|
|
||||||
showResult('dailyReportResult', null, false, true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${getApiUrl()}/daily-report/trigger`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
showStatus('dailyReportStatus', 'success', 'Успешно');
|
|
||||||
showResult('dailyReportResult', data, false);
|
|
||||||
} else {
|
|
||||||
showStatus('dailyReportStatus', 'error', 'Ошибка');
|
|
||||||
showResult('dailyReportResult', data, true);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('dailyReportStatus', 'error', 'Ошибка');
|
|
||||||
showResult('dailyReportResult', { error: error.message }, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Разрешаем отправку формы по Enter (Ctrl+Enter для textarea)
|
|
||||||
document.getElementById('messageText').addEventListener('keydown', function(e) {
|
|
||||||
if (e.ctrlKey && e.key === 'Enter') {
|
|
||||||
sendMessage();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 "Теперь приложение будет автоматически применять новые миграции при запуске."
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# Database Configuration
|
|
||||||
DB_HOST=localhost
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_USER=playeng
|
|
||||||
DB_PASSWORD=playeng
|
|
||||||
DB_NAME=playeng
|
|
||||||
|
|
||||||
# Server Configuration
|
|
||||||
PORT=8080
|
|
||||||
|
|
||||||
# Telegram Bot Configuration
|
|
||||||
# Bot Token и Chat ID настраиваются через UI приложения в разделе "Интеграции" -> "Telegram"
|
|
||||||
# Get token from @BotFather in Telegram: https://t.me/botfather
|
|
||||||
|
|
||||||
# Scheduler Configuration
|
|
||||||
# Часовой пояс для планировщика (формат IANA: Europe/Moscow, America/New_York и т.д.)
|
|
||||||
# ВАЖНО: Если не указан, используется UTC!
|
|
||||||
TIMEZONE=Europe/Moscow
|
|
||||||
@@ -1,11 +1,27 @@
|
|||||||
module play-eng-backend
|
module play-eng-backend
|
||||||
|
|
||||||
go 1.21
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
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/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/gorilla/mux v1.8.1
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +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 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
|
||||||
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
|
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 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
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 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
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=
|
||||||
|
|||||||
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);
|
||||||
@@ -45,7 +45,7 @@ docker-compose exec db psql -U playeng -d playeng -f /migrations/001_create_sche
|
|||||||
- `goal_week` (INTEGER NOT NULL)
|
- `goal_week` (INTEGER NOT NULL)
|
||||||
- `min_goal_score` (NUMERIC(10,4) NOT NULL, DEFAULT 0)
|
- `min_goal_score` (NUMERIC(10,4) NOT NULL, DEFAULT 0)
|
||||||
- `max_goal_score` (NUMERIC(10,4))
|
- `max_goal_score` (NUMERIC(10,4))
|
||||||
- `actual_score` (NUMERIC(10,4), DEFAULT 0)
|
- `max_score` (NUMERIC(10,4), NULL) — snapshot max на неделю (заполняется только для новых недель)
|
||||||
- `priority` (SMALLINT)
|
- `priority` (SMALLINT)
|
||||||
- UNIQUE CONSTRAINT: `(project_id, goal_year, goal_week)`
|
- UNIQUE CONSTRAINT: `(project_id, goal_year, goal_week)`
|
||||||
|
|
||||||
@@ -56,6 +56,7 @@ docker-compose exec db psql -U playeng -d playeng -f /migrations/001_create_sche
|
|||||||
- `report_year` (INTEGER)
|
- `report_year` (INTEGER)
|
||||||
- `report_week` (INTEGER)
|
- `report_week` (INTEGER)
|
||||||
- `total_score` (NUMERIC)
|
- `total_score` (NUMERIC)
|
||||||
|
- `normalized_total_score` (NUMERIC)
|
||||||
|
|
||||||
## Миграции
|
## Миграции
|
||||||
|
|
||||||
@@ -67,6 +68,8 @@ docker-compose exec db psql -U playeng -d playeng -f /migrations/001_create_sche
|
|||||||
4. **004_add_config_dictionaries.sql** - Добавление связи между конфигурациями и словарями
|
4. **004_add_config_dictionaries.sql** - Добавление связи между конфигурациями и словарями
|
||||||
5. **005_fix_weekly_report_mv.sql** - Исправление использования ISOYEAR вместо YEAR для корректной работы на границе года
|
5. **005_fix_weekly_report_mv.sql** - Исправление использования ISOYEAR вместо YEAR для корректной работы на границе года
|
||||||
6. **006_fix_weekly_report_mv_structure.sql** - Исправление структуры view (добавление LEFT JOIN для включения всех проектов)
|
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)
|
||||||
|
|
||||||
### Применение миграций
|
### Применение миграций
|
||||||
|
|
||||||
@@ -75,6 +78,8 @@ docker-compose exec db psql -U playeng -d playeng -f /migrations/001_create_sche
|
|||||||
```bash
|
```bash
|
||||||
psql -U playeng -d playeng -f migrations/005_fix_weekly_report_mv.sql
|
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/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:
|
Или через docker-compose:
|
||||||
@@ -82,6 +87,8 @@ psql -U playeng -d playeng -f migrations/006_fix_weekly_report_mv_structure.sql
|
|||||||
```bash
|
```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/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/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
|
## Обновление Materialized View
|
||||||
|
|||||||
@@ -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.';
|
||||||
|
|
||||||
|
|
||||||
86
play-life-backend/migrations_old/019_add_wishlist.sql
Normal file
86
play-life-backend/migrations_old/019_add_wishlist.sql
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
-- Migration: Add wishlist tables
|
||||||
|
-- This script creates tables for wishlist management system
|
||||||
|
-- Supports multiple unlock conditions per wishlist item (AND logic)
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: wishlist_items
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS 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
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_items_user_id ON wishlist_items(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_items_user_deleted ON wishlist_items(user_id, deleted);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_items_user_completed ON wishlist_items(user_id, completed, deleted);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: task_conditions
|
||||||
|
-- ============================================
|
||||||
|
-- Reusable conditions for task completion
|
||||||
|
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_task_conditions_task_id ON task_conditions(task_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: score_conditions
|
||||||
|
-- ============================================
|
||||||
|
-- Reusable conditions for project points
|
||||||
|
CREATE TABLE IF NOT EXISTS score_conditions (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
required_points NUMERIC(10,4) NOT NULL,
|
||||||
|
period_type VARCHAR(20), -- 'week', 'month', 'year', NULL (all time)
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT unique_score_condition UNIQUE (project_id, required_points, period_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_score_conditions_project_id ON score_conditions(project_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: wishlist_conditions
|
||||||
|
-- ============================================
|
||||||
|
-- Links wishlist items to unlock conditions
|
||||||
|
CREATE TABLE IF NOT EXISTS 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,
|
||||||
|
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 IF NOT EXISTS idx_wishlist_conditions_item_id ON wishlist_conditions(wishlist_item_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_conditions_item_order ON wishlist_conditions(wishlist_item_id, display_order);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_conditions_task_condition_id ON wishlist_conditions(task_condition_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_conditions_score_condition_id ON wishlist_conditions(score_condition_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
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';
|
||||||
|
|
||||||
|
COMMENT ON TABLE task_conditions IS 'Reusable unlock conditions based on task completion';
|
||||||
|
COMMENT ON TABLE score_conditions IS 'Reusable unlock conditions based on project points';
|
||||||
|
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';
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Migration: Change period_type to start_date in score_conditions
|
||||||
|
-- This allows specifying a start date for counting points instead of period type
|
||||||
|
-- Date can be in the past or future, NULL means count all time
|
||||||
|
|
||||||
|
-- Добавляем новое поле start_date
|
||||||
|
ALTER TABLE score_conditions
|
||||||
|
ADD COLUMN IF NOT EXISTS start_date DATE;
|
||||||
|
|
||||||
|
-- Миграция данных: для существующих записей с period_type устанавливаем start_date
|
||||||
|
-- Если period_type = 'week', то start_date = начало текущей недели
|
||||||
|
-- Если period_type = 'month', то start_date = начало текущего месяца
|
||||||
|
-- Если period_type = 'year', то start_date = начало текущего года
|
||||||
|
-- Если period_type IS NULL, то start_date = NULL (за всё время)
|
||||||
|
UPDATE score_conditions
|
||||||
|
SET start_date = CASE
|
||||||
|
WHEN period_type = 'week' THEN DATE_TRUNC('week', CURRENT_DATE)::DATE
|
||||||
|
WHEN period_type = 'month' THEN DATE_TRUNC('month', CURRENT_DATE)::DATE
|
||||||
|
WHEN period_type = 'year' THEN DATE_TRUNC('year', CURRENT_DATE)::DATE
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
WHERE start_date IS NULL;
|
||||||
|
|
||||||
|
-- Обновляем уникальное ограничение (удаляем старое, добавляем новое)
|
||||||
|
ALTER TABLE score_conditions
|
||||||
|
DROP CONSTRAINT IF EXISTS unique_score_condition;
|
||||||
|
|
||||||
|
ALTER TABLE score_conditions
|
||||||
|
ADD CONSTRAINT unique_score_condition
|
||||||
|
UNIQUE (project_id, required_points, start_date);
|
||||||
|
|
||||||
|
-- Обновляем комментарии
|
||||||
|
COMMENT ON COLUMN score_conditions.start_date IS 'Date from which to start counting points. NULL means count all time.';
|
||||||
|
|
||||||
|
-- Примечание: поле period_type оставляем пока для обратной совместимости
|
||||||
|
-- Его можно будет удалить позже после проверки, что всё работает:
|
||||||
|
-- ALTER TABLE score_conditions DROP COLUMN period_type;
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Migration: Add wishlist_id to tasks table for linking tasks to wishlist items
|
||||||
|
-- This allows creating tasks directly from wishlist items and tracking the relationship
|
||||||
|
|
||||||
|
-- Добавляем поле wishlist_id в таблицу tasks
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS wishlist_id INTEGER REFERENCES wishlist_items(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Создаём индекс для быстрого поиска задач по wishlist_id
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_wishlist_id ON tasks(wishlist_id);
|
||||||
|
|
||||||
|
-- Уникальный индекс: только одна незавершённая задача на желание
|
||||||
|
-- Это предотвращает создание нескольких задач для одного желания
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_wishlist_id_unique
|
||||||
|
ON tasks(wishlist_id) WHERE wishlist_id IS NOT NULL AND deleted = FALSE;
|
||||||
|
|
||||||
|
-- Добавляем комментарий для документации
|
||||||
|
COMMENT ON COLUMN tasks.wishlist_id IS 'Link to wishlist item that this task fulfills. NULL if task is not linked to any wishlist item.';
|
||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Migration: Refactor configs to link via tasks.config_id
|
||||||
|
-- This migration adds config_id to tasks table and migrates existing configs to tasks
|
||||||
|
-- After migration: configs only contain words_count, max_cards (name and try_message removed)
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Step 1: Add config_id to tasks
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS config_id INTEGER REFERENCES configs(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_config_id ON tasks(config_id);
|
||||||
|
|
||||||
|
-- Unique index: only one task per config
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_config_id_unique
|
||||||
|
ON tasks(config_id) WHERE config_id IS NOT NULL AND deleted = FALSE;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN tasks.config_id IS 'Link to test config. NULL if task is not a test.';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Step 2: Migrate existing configs to tasks
|
||||||
|
-- Create a task for each config that doesn't have one yet
|
||||||
|
-- ============================================
|
||||||
|
INSERT INTO tasks (user_id, name, reward_message, repetition_period, repetition_date, config_id)
|
||||||
|
SELECT
|
||||||
|
c.user_id,
|
||||||
|
c.name, -- Config name -> Task name
|
||||||
|
c.try_message, -- try_message -> reward_message
|
||||||
|
'0 day'::INTERVAL, -- repetition_period = 0 (infinite task)
|
||||||
|
'0 week', -- repetition_date = 0 (infinite task)
|
||||||
|
c.id -- Link to config
|
||||||
|
FROM configs c
|
||||||
|
WHERE c.name IS NOT NULL -- Only configs with names
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM tasks t WHERE t.config_id = c.id AND t.deleted = FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Step 3: Remove name and try_message from configs
|
||||||
|
-- These are now stored in the linked task
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE configs DROP COLUMN IF EXISTS name;
|
||||||
|
ALTER TABLE configs DROP COLUMN IF EXISTS try_message;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments for documentation
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON TABLE configs IS 'Test configurations (words_count, max_cards, dictionary associations). Linked to tasks via tasks.config_id.';
|
||||||
|
|
||||||
|
|
||||||
116
play-life-backend/migrations_old/023_add_wishlist_boards.sql
Normal file
116
play-life-backend/migrations_old/023_add_wishlist_boards.sql
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
-- Migration: Add wishlist boards for multi-user collaboration
|
||||||
|
-- Each user can have multiple boards, share them via invite links,
|
||||||
|
-- and collaborate with other users on shared wishes
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: wishlist_boards (доски желаний)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_wishlist_boards_owner_id ON wishlist_boards(owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_boards_invite_token ON wishlist_boards(invite_token)
|
||||||
|
WHERE invite_token IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_boards_owner_deleted ON wishlist_boards(owner_id, deleted);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Table: wishlist_board_members (участники доски)
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_board_members_board_id ON wishlist_board_members(board_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_board_members_user_id ON wishlist_board_members(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Modify: wishlist_items - добавляем board_id и author_id
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE wishlist_items
|
||||||
|
ADD COLUMN IF NOT EXISTS board_id INTEGER REFERENCES wishlist_boards(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE wishlist_items
|
||||||
|
ADD COLUMN IF NOT EXISTS author_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_items_board_id ON wishlist_items(board_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_items_author_id ON wishlist_items(author_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Modify: wishlist_conditions - добавляем user_id для персональных целей
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE wishlist_conditions
|
||||||
|
ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_conditions_user_id ON wishlist_conditions(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Modify: tasks - добавляем политику награждения для wishlist задач
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS reward_policy VARCHAR(20) DEFAULT 'personal';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN tasks.reward_policy IS
|
||||||
|
'For wishlist tasks: personal = only if user completes, shared = anyone completes';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Миграция данных: Этап 1 - создаём персональные доски
|
||||||
|
-- ============================================
|
||||||
|
-- Создаём доску "Мои желания" для каждого пользователя с желаниями
|
||||||
|
INSERT INTO wishlist_boards (owner_id, name)
|
||||||
|
SELECT DISTINCT user_id, 'Мои желания'
|
||||||
|
FROM wishlist_items
|
||||||
|
WHERE user_id IS NOT NULL
|
||||||
|
AND deleted = FALSE
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM wishlist_boards wb
|
||||||
|
WHERE wb.owner_id = wishlist_items.user_id AND wb.name = 'Мои желания'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Миграция данных: Этап 2 - привязываем желания к доскам
|
||||||
|
-- ============================================
|
||||||
|
UPDATE wishlist_items wi
|
||||||
|
SET
|
||||||
|
board_id = wb.id,
|
||||||
|
author_id = COALESCE(wi.author_id, wi.user_id)
|
||||||
|
FROM wishlist_boards wb
|
||||||
|
WHERE wi.board_id IS NULL
|
||||||
|
AND wi.user_id = wb.owner_id
|
||||||
|
AND wb.name = 'Мои желания';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Миграция данных: Этап 3 - заполняем user_id в условиях
|
||||||
|
-- ============================================
|
||||||
|
UPDATE wishlist_conditions wc
|
||||||
|
SET user_id = wi.user_id
|
||||||
|
FROM wishlist_items wi
|
||||||
|
WHERE wc.wishlist_item_id = wi.id
|
||||||
|
AND wc.user_id IS NULL;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments
|
||||||
|
-- ============================================
|
||||||
|
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_conditions.user_id IS 'Owner of this condition. Each user has their own goals on shared boards.';
|
||||||
|
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';
|
||||||
|
|
||||||
13
play-life-backend/migrations_old/024_add_reward_policy.sql
Normal file
13
play-life-backend/migrations_old/024_add_reward_policy.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Migration: Add reward_policy to tasks table
|
||||||
|
-- This migration adds reward_policy column for wishlist tasks
|
||||||
|
-- If the column already exists (from migration 023), this will be a no-op
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Modify: tasks - добавляем политику награждения для wishlist задач
|
||||||
|
-- ============================================
|
||||||
|
ALTER TABLE tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS reward_policy VARCHAR(20) DEFAULT 'personal';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN tasks.reward_policy IS
|
||||||
|
'For wishlist tasks: personal = only if user completes, shared = anyone completes';
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Migration: Remove wishlist conditions without user_id
|
||||||
|
-- These conditions should not exist as every condition must have an owner
|
||||||
|
-- This migration removes orphaned conditions that were created before the fix
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Remove conditions without user_id
|
||||||
|
-- ============================================
|
||||||
|
DELETE FROM wishlist_conditions WHERE user_id IS NULL;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- Comments
|
||||||
|
-- ============================================
|
||||||
|
COMMENT ON COLUMN wishlist_conditions.user_id IS 'Owner of this condition. Each user has their own goals on shared boards. Required field.';
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Migration: Add weekly_goals.max_score snapshot column and drop unused actual_score
|
||||||
|
-- Date: 2026-01-24
|
||||||
|
|
||||||
|
ALTER TABLE weekly_goals
|
||||||
|
DROP COLUMN IF EXISTS actual_score;
|
||||||
|
|
||||||
|
-- max_score is a snapshot of max_goal_score for a week, filled only for new weeks by cron
|
||||||
|
ALTER TABLE weekly_goals
|
||||||
|
ADD COLUMN IF NOT EXISTS max_score NUMERIC(10,4);
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- Migration: Add normalized_total_score to weekly_report_mv using weekly_goals.max_score
|
||||||
|
-- Date: 2026-01-24
|
||||||
|
--
|
||||||
|
-- normalized_total_score = LEAST(total_score, max_score) if max_score is set, else total_score.
|
||||||
|
-- Note: max_score is a snapshot field (filled only for new weeks by cron).
|
||||||
|
|
||||||
|
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 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. Adds normalized_total_score using weekly_goals.max_score snapshot.';
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Migration: Optimize task queries with composite index
|
||||||
|
-- Date: 2026-01-24
|
||||||
|
--
|
||||||
|
-- This migration adds a composite index to optimize the task detail query:
|
||||||
|
-- WHERE id = $1 AND user_id = $2 AND deleted = FALSE
|
||||||
|
--
|
||||||
|
-- The index uses a partial index with WHERE deleted = FALSE to reduce index size
|
||||||
|
-- and improve query performance for active (non-deleted) tasks.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_id_user_deleted
|
||||||
|
ON tasks(id, user_id, deleted)
|
||||||
|
WHERE deleted = FALSE;
|
||||||
|
|
||||||
|
COMMENT ON INDEX idx_tasks_id_user_deleted IS 'Composite index for optimizing task detail queries with id, user_id, and deleted filter. Partial index for non-deleted tasks only.';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- Migration: Add covering indexes for task detail queries
|
||||||
|
-- Date: 2026-01-25
|
||||||
|
--
|
||||||
|
-- This migration adds covering indexes to optimize queries by including
|
||||||
|
-- all needed columns in the index, avoiding table lookups.
|
||||||
|
--
|
||||||
|
-- Covering indexes allow PostgreSQL to perform index-only scans,
|
||||||
|
-- getting all data directly from the index without accessing the table.
|
||||||
|
|
||||||
|
-- Covering index for subtasks query
|
||||||
|
-- Includes all columns needed for subtasks selection to avoid table lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS 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;
|
||||||
|
|
||||||
|
-- Covering index for wishlist name lookup
|
||||||
|
-- Includes name and deleted flag for quick lookup without table access
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wishlist_items_id_deleted_covering
|
||||||
|
ON wishlist_items(id, deleted)
|
||||||
|
INCLUDE (name)
|
||||||
|
WHERE deleted = FALSE;
|
||||||
|
|
||||||
|
COMMENT ON INDEX idx_tasks_parent_deleted_covering IS 'Covering index for subtasks query - includes all selected columns to avoid table lookups. Enables index-only scans for better performance.';
|
||||||
|
COMMENT ON INDEX idx_wishlist_items_id_deleted_covering IS 'Covering index for wishlist name lookup - includes name to avoid table lookup. Enables index-only scans for better performance.';
|
||||||
15
play-life-backend/migrations_old/README.md
Normal file
15
play-life-backend/migrations_old/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Архив старых миграций
|
||||||
|
|
||||||
|
Эта директория содержит старые SQL миграции (001-029), которые были заменены baseline миграцией `000001_baseline.up.sql`.
|
||||||
|
|
||||||
|
## Примечание
|
||||||
|
|
||||||
|
Эти миграции сохранены только для справки и истории. Они **не должны применяться** в новых установках или после перехода на golang-migrate.
|
||||||
|
|
||||||
|
## Новые миграции
|
||||||
|
|
||||||
|
Все новые миграции должны создаваться в формате golang-migrate:
|
||||||
|
- `000002_*.up.sql` - миграция вверх
|
||||||
|
- `000002_*.down.sql` - миграция вниз (откат)
|
||||||
|
|
||||||
|
Используйте команду `migrate create -ext sql -dir migrations -seq <name>` для создания новых миграций.
|
||||||
BIN
play-life-backend/play-eng-backend
Executable file
BIN
play-life-backend/play-eng-backend
Executable file
Binary file not shown.
347
play-life-backend/test_baseline.sh
Executable file
347
play-life-backend/test_baseline.sh
Executable file
@@ -0,0 +1,347 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт для тестирования baseline миграции на чистой БД
|
||||||
|
# Создает тестовую БД, применяет baseline, и сравнивает схему с production
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Цвета для вывода
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
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}
|
||||||
|
|
||||||
|
TEST_DB_NAME="playeng_baseline_test_$$"
|
||||||
|
MIGRATIONS_PATH="migrations"
|
||||||
|
TMP_DIR=$(mktemp -d)
|
||||||
|
|
||||||
|
echo "=== Тестирование baseline миграции на чистой БД ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Добавляем ~/go/bin в PATH если migrate не найден
|
||||||
|
if ! command -v migrate &> /dev/null; then
|
||||||
|
export PATH="$HOME/go/bin:$PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Проверяем наличие необходимых инструментов
|
||||||
|
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
|
||||||
|
|
||||||
|
# Определяем способ выполнения PostgreSQL команд
|
||||||
|
PG_DUMP_CMD=""
|
||||||
|
PG_PSQL_CMD=""
|
||||||
|
POSTGRES_CONTAINER=""
|
||||||
|
if command -v pg_dump &> /dev/null; then
|
||||||
|
PG_DUMP_CMD="pg_dump"
|
||||||
|
PG_PSQL_CMD="psql"
|
||||||
|
else
|
||||||
|
# Пытаемся найти PostgreSQL контейнер
|
||||||
|
if command -v docker &> /dev/null; then
|
||||||
|
POSTGRES_CONTAINER=$(docker ps --format "{{.Names}}" 2>/dev/null | grep -iE "(postgres|db)" | head -1)
|
||||||
|
if [ -n "$POSTGRES_CONTAINER" ]; then
|
||||||
|
PG_DUMP_CMD="docker exec $POSTGRES_CONTAINER pg_dump"
|
||||||
|
PG_PSQL_CMD="docker exec -i $POSTGRES_CONTAINER psql"
|
||||||
|
echo -e "${BLUE}Используется PostgreSQL из Docker контейнера: $POSTGRES_CONTAINER${NC}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
HAS_PG_DUMP=false
|
||||||
|
if [ -n "$PG_DUMP_CMD" ]; then
|
||||||
|
HAS_PG_DUMP=true
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}Предупреждение: pg_dump не найден. Сравнение схем будет пропущено.${NC}"
|
||||||
|
echo " Для полного тестирования установите PostgreSQL client tools"
|
||||||
|
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 " Test DB: $TEST_DB_NAME"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверяем подключение к БД
|
||||||
|
echo "1. Проверка подключения к БД..."
|
||||||
|
if [ -n "$POSTGRES_CONTAINER" ]; then
|
||||||
|
# Используем Docker
|
||||||
|
echo "SELECT 1;" | $PG_PSQL_CMD -U $DB_USER -d postgres > /dev/null 2>&1
|
||||||
|
elif [ -n "$PG_PSQL_CMD" ]; then
|
||||||
|
# Используем локальный psql
|
||||||
|
PGPASSWORD=$DB_PASSWORD $PG_PSQL_CMD \
|
||||||
|
-h $DB_HOST \
|
||||||
|
-p $DB_PORT \
|
||||||
|
-U $DB_USER \
|
||||||
|
-d postgres \
|
||||||
|
-c "SELECT 1;" > /dev/null 2>&1
|
||||||
|
else
|
||||||
|
# Пытаемся через стандартный psql
|
||||||
|
PGPASSWORD=$DB_PASSWORD psql \
|
||||||
|
-h $DB_HOST \
|
||||||
|
-p $DB_PORT \
|
||||||
|
-U $DB_USER \
|
||||||
|
-d postgres \
|
||||||
|
-c "SELECT 1;" > /dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Ошибка: Не удалось подключиться к БД${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Подключение успешно${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Создаем тестовую БД
|
||||||
|
echo "2. Создание тестовой БД..."
|
||||||
|
if [ -n "$POSTGRES_CONTAINER" ]; then
|
||||||
|
echo "CREATE DATABASE $TEST_DB_NAME;" | $PG_PSQL_CMD -U $DB_USER -d postgres > /dev/null 2>&1
|
||||||
|
elif [ -n "$PG_PSQL_CMD" ]; then
|
||||||
|
PGPASSWORD=$DB_PASSWORD $PG_PSQL_CMD \
|
||||||
|
-h $DB_HOST \
|
||||||
|
-p $DB_PORT \
|
||||||
|
-U $DB_USER \
|
||||||
|
-d postgres \
|
||||||
|
-c "CREATE DATABASE $TEST_DB_NAME;" > /dev/null 2>&1
|
||||||
|
else
|
||||||
|
PGPASSWORD=$DB_PASSWORD psql \
|
||||||
|
-h $DB_HOST \
|
||||||
|
-p $DB_PORT \
|
||||||
|
-U $DB_USER \
|
||||||
|
-d postgres \
|
||||||
|
-c "CREATE DATABASE $TEST_DB_NAME;" > /dev/null 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Ошибка: Не удалось создать тестовую БД${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Тестовая БД создана: $TEST_DB_NAME${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Ждем немного, чтобы БД точно создалась
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Проверяем, что БД создана
|
||||||
|
echo "3. Проверка существования тестовой БД..."
|
||||||
|
if [ -n "$POSTGRES_CONTAINER" ]; then
|
||||||
|
if echo "SELECT 1 FROM pg_database WHERE datname='$TEST_DB_NAME';" | $PG_PSQL_CMD -U $DB_USER -d postgres -t | grep -q 1; then
|
||||||
|
echo -e "${GREEN}✓ БД подтверждена${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Ошибка: БД не найдена после создания${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Применяем baseline миграцию
|
||||||
|
echo "4. Применение baseline миграции..."
|
||||||
|
cd "$(dirname "$0")" || exit 1
|
||||||
|
|
||||||
|
if [ -n "$POSTGRES_CONTAINER" ]; then
|
||||||
|
# Для Docker контейнеров используем psql напрямую, так как migrate может иметь проблемы с подключением
|
||||||
|
echo -e "${BLUE}Применение миграции через psql (Docker)...${NC}"
|
||||||
|
if [ -f "$MIGRATIONS_PATH/000001_baseline.up.sql" ]; then
|
||||||
|
if cat "$MIGRATIONS_PATH/000001_baseline.up.sql" | $PG_PSQL_CMD -U $DB_USER -d $TEST_DB_NAME > /dev/null 2>&1; then
|
||||||
|
echo -e "${GREEN}✓ Миграция применена через psql${NC}"
|
||||||
|
# Создаем таблицу schema_migrations вручную для migrate
|
||||||
|
echo "CREATE TABLE IF NOT EXISTS schema_migrations (version bigint NOT NULL PRIMARY KEY, dirty boolean NOT NULL);" | $PG_PSQL_CMD -U $DB_USER -d $TEST_DB_NAME > /dev/null 2>&1
|
||||||
|
echo "INSERT INTO schema_migrations (version, dirty) VALUES (1, false) ON CONFLICT (version) DO UPDATE SET dirty = false;" | $PG_PSQL_CMD -U $DB_USER -d $TEST_DB_NAME > /dev/null 2>&1
|
||||||
|
MIGRATE_SUCCESS=false # Устанавливаем в false, чтобы использовать psql для проверки версии
|
||||||
|
else
|
||||||
|
echo -e "${RED}Ошибка: Не удалось применить миграцию через psql${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED}Ошибка: Файл миграции не найден${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DATABASE_URL="postgres://$DB_USER:$DB_PASSWORD@localhost:$DB_PORT/$TEST_DB_NAME?sslmode=disable"
|
||||||
|
else
|
||||||
|
DATABASE_URL="postgres://$DB_USER:$DB_PASSWORD@$DB_HOST:$DB_PORT/$TEST_DB_NAME?sslmode=disable"
|
||||||
|
if ! migrate -path "$MIGRATIONS_PATH" -database "$DATABASE_URL" up; then
|
||||||
|
echo -e "${RED}Ошибка: Не удалось применить baseline миграцию${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Baseline миграция применена${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверяем версию миграции
|
||||||
|
echo "5. Проверка версии миграции..."
|
||||||
|
if [ -n "$POSTGRES_CONTAINER" ] && [ "${MIGRATE_SUCCESS:-false}" = "false" ]; then
|
||||||
|
# Проверяем версию через psql
|
||||||
|
VERSION=$(echo "SELECT version FROM schema_migrations;" | $PG_PSQL_CMD -U $DB_USER -d $TEST_DB_NAME -t 2>/dev/null | tr -d ' ' | head -1)
|
||||||
|
if [ -n "$VERSION" ] && [ "$VERSION" != "" ]; then
|
||||||
|
echo " Версия: $VERSION"
|
||||||
|
if [ "$VERSION" = "1" ]; then
|
||||||
|
echo -e "${GREEN}✓ Версия миграции корректна${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠ Неожиданная версия миграции: $VERSION${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠ Не удалось определить версию миграции${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Используем migrate для проверки версии
|
||||||
|
VERSION=$(migrate -path "$MIGRATIONS_PATH" -database "$DATABASE_URL" version 2>&1)
|
||||||
|
echo " Версия: $VERSION"
|
||||||
|
|
||||||
|
if echo "$VERSION" | grep -qE "^1"; then
|
||||||
|
echo -e "${GREEN}✓ Версия миграции корректна${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠ Неожиданная версия миграции${NC}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Экспортируем схему из тестовой БД (если pg_dump доступен)
|
||||||
|
if [ "$HAS_PG_DUMP" = true ]; then
|
||||||
|
echo "6. Экспорт схемы из тестовой БД..."
|
||||||
|
if [ -n "$POSTGRES_CONTAINER" ]; then
|
||||||
|
$PG_DUMP_CMD -U $DB_USER -d $TEST_DB_NAME --schema-only --no-owner --no-privileges > "$TMP_DIR/baseline_schema.sql"
|
||||||
|
else
|
||||||
|
PGPASSWORD=$DB_PASSWORD $PG_DUMP_CMD \
|
||||||
|
-h $DB_HOST \
|
||||||
|
-p $DB_PORT \
|
||||||
|
-U $DB_USER \
|
||||||
|
-d $TEST_DB_NAME \
|
||||||
|
--schema-only \
|
||||||
|
--no-owner \
|
||||||
|
--no-privileges \
|
||||||
|
-f "$TMP_DIR/baseline_schema.sql"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Ошибка: Не удалось экспортировать схему${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Схема экспортирована${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Пытаемся экспортировать схему из production БД для сравнения
|
||||||
|
echo "7. Экспорт схемы из production БД для сравнения..."
|
||||||
|
if [ -n "$POSTGRES_CONTAINER" ]; then
|
||||||
|
if $PG_DUMP_CMD -U $DB_USER -d $DB_NAME --schema-only --no-owner --no-privileges > "$TMP_DIR/production_schema.sql" 2>/dev/null; then
|
||||||
|
PROD_EXPORT_SUCCESS=true
|
||||||
|
else
|
||||||
|
PROD_EXPORT_SUCCESS=false
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if PGPASSWORD=$DB_PASSWORD $PG_DUMP_CMD \
|
||||||
|
-h $DB_HOST \
|
||||||
|
-p $DB_PORT \
|
||||||
|
-U $DB_USER \
|
||||||
|
-d $DB_NAME \
|
||||||
|
--schema-only \
|
||||||
|
--no-owner \
|
||||||
|
--no-privileges \
|
||||||
|
-f "$TMP_DIR/production_schema.sql" 2>/dev/null; then
|
||||||
|
PROD_EXPORT_SUCCESS=true
|
||||||
|
else
|
||||||
|
PROD_EXPORT_SUCCESS=false
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$PROD_EXPORT_SUCCESS" = true ]; then
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Схема production экспортирована${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Сравниваем схемы
|
||||||
|
echo "8. Сравнение схем..."
|
||||||
|
|
||||||
|
# Подсчитываем объекты
|
||||||
|
echo -e "${BLUE}Таблицы:${NC}"
|
||||||
|
BASELINE_TABLES=$(grep -c "CREATE TABLE" "$TMP_DIR/baseline_schema.sql" || echo "0")
|
||||||
|
PROD_TABLES=$(grep -c "CREATE TABLE" "$TMP_DIR/production_schema.sql" || echo "0")
|
||||||
|
echo " Baseline: $BASELINE_TABLES"
|
||||||
|
echo " Production: $PROD_TABLES"
|
||||||
|
|
||||||
|
if [ "$BASELINE_TABLES" -eq "$PROD_TABLES" ]; then
|
||||||
|
echo -e " ${GREEN}✓ Количество таблиц совпадает${NC}"
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}⚠ Количество таблиц не совпадает${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}Индексы:${NC}"
|
||||||
|
BASELINE_INDEXES=$(grep -c "CREATE.*INDEX" "$TMP_DIR/baseline_schema.sql" || echo "0")
|
||||||
|
PROD_INDEXES=$(grep -c "CREATE.*INDEX" "$TMP_DIR/production_schema.sql" || echo "0")
|
||||||
|
echo " Baseline: $BASELINE_INDEXES"
|
||||||
|
echo " Production: $PROD_INDEXES"
|
||||||
|
|
||||||
|
if [ "$BASELINE_INDEXES" -eq "$PROD_INDEXES" ]; then
|
||||||
|
echo -e " ${GREEN}✓ Количество индексов совпадает${NC}"
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}⚠ Количество индексов не совпадает${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}Materialized Views:${NC}"
|
||||||
|
BASELINE_MV=$(grep -c "CREATE MATERIALIZED VIEW" "$TMP_DIR/baseline_schema.sql" || echo "0")
|
||||||
|
PROD_MV=$(grep -c "CREATE MATERIALIZED VIEW" "$TMP_DIR/production_schema.sql" || echo "0")
|
||||||
|
echo " Baseline: $BASELINE_MV"
|
||||||
|
echo " Production: $PROD_MV"
|
||||||
|
|
||||||
|
if [ "$BASELINE_MV" -eq "$PROD_MV" ]; then
|
||||||
|
echo -e " ${GREEN}✓ Количество materialized views совпадает${NC}"
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}⚠ Количество materialized views не совпадает${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Для детального сравнения выполните:"
|
||||||
|
echo " diff $TMP_DIR/baseline_schema.sql $TMP_DIR/production_schema.sql"
|
||||||
|
echo ""
|
||||||
|
echo "Или используйте:"
|
||||||
|
echo " diff -u $TMP_DIR/baseline_schema.sql $TMP_DIR/production_schema.sql | less"
|
||||||
|
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠ Не удалось экспортировать схему production БД${NC}"
|
||||||
|
echo " Продолжаем без сравнения"
|
||||||
|
echo ""
|
||||||
|
echo "Схема baseline сохранена в: $TMP_DIR/baseline_schema.sql"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "6. Пропуск экспорта схемы (pg_dump недоступен)"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Для полного тестирования установите PostgreSQL client tools:${NC}"
|
||||||
|
echo " macOS: brew install postgresql"
|
||||||
|
echo " или используйте Docker контейнер с PostgreSQL"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Тестирование завершено ==="
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✓ Baseline миграция успешно применена к чистой БД${NC}"
|
||||||
|
echo ""
|
||||||
144
play-life-backend/validate_baseline.sh
Executable file
144
play-life-backend/validate_baseline.sh
Executable file
@@ -0,0 +1,144 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт для проверки полноты baseline миграции
|
||||||
|
# Сравнивает текущую схему БД с 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}
|
||||||
|
|
||||||
|
echo "=== Проверка полноты baseline миграции ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверяем наличие pg_dump
|
||||||
|
if ! command -v pg_dump &> /dev/null; then
|
||||||
|
echo -e "${RED}Ошибка: pg_dump не найден. Установите PostgreSQL client tools.${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Создаем временную директорию
|
||||||
|
TMP_DIR=$(mktemp -d)
|
||||||
|
trap "rm -rf $TMP_DIR" EXIT
|
||||||
|
|
||||||
|
echo "1. Экспортируем текущую схему БД..."
|
||||||
|
PGPASSWORD=$DB_PASSWORD pg_dump \
|
||||||
|
-h $DB_HOST \
|
||||||
|
-p $DB_PORT \
|
||||||
|
-U $DB_USER \
|
||||||
|
-d $DB_NAME \
|
||||||
|
--schema-only \
|
||||||
|
--no-owner \
|
||||||
|
--no-privileges \
|
||||||
|
-f "$TMP_DIR/current_schema.sql"
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${RED}Ошибка: Не удалось экспортировать схему БД${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Схема экспортирована${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Применяем baseline миграцию к временной БД для сравнения
|
||||||
|
echo "2. Создаем временную БД для проверки baseline..."
|
||||||
|
TEMP_DB_NAME="playeng_baseline_test_$$"
|
||||||
|
PGPASSWORD=$DB_PASSWORD psql \
|
||||||
|
-h $DB_HOST \
|
||||||
|
-p $DB_PORT \
|
||||||
|
-U $DB_USER \
|
||||||
|
-d postgres \
|
||||||
|
-c "CREATE DATABASE $TEMP_DB_NAME;" > /dev/null 2>&1
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${YELLOW}Предупреждение: Не удалось создать временную БД. Продолжаем без неё.${NC}"
|
||||||
|
TEMP_DB_NAME=""
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✓ Временная БД создана${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Очистка временной БД при выходе
|
||||||
|
if [ -n "$TEMP_DB_NAME" ]; then
|
||||||
|
trap "PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d postgres -c 'DROP DATABASE IF EXISTS $TEMP_DB_NAME;' > /dev/null 2>&1; rm -rf $TMP_DIR" EXIT
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "3. Анализ схемы..."
|
||||||
|
|
||||||
|
# Извлекаем только CREATE TABLE, CREATE INDEX, CREATE VIEW и т.д. из текущей схемы
|
||||||
|
grep -E "^(CREATE|ALTER|COMMENT)" "$TMP_DIR/current_schema.sql" | \
|
||||||
|
sed 's/--.*$//' | \
|
||||||
|
tr -d '\n' | \
|
||||||
|
sed 's/;/;\n/g' | \
|
||||||
|
sort > "$TMP_DIR/current_clean.sql"
|
||||||
|
|
||||||
|
# Извлекаем из baseline миграции
|
||||||
|
BASELINE_FILE="play-life-backend/migrations/000001_baseline.up.sql"
|
||||||
|
if [ ! -f "$BASELINE_FILE" ]; then
|
||||||
|
echo -e "${RED}Ошибка: Baseline файл не найден: $BASELINE_FILE${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
grep -E "^(CREATE|ALTER|COMMENT)" "$BASELINE_FILE" | \
|
||||||
|
sed 's/--.*$//' | \
|
||||||
|
tr -d '\n' | \
|
||||||
|
sed 's/;/;\n/g' | \
|
||||||
|
sort > "$TMP_DIR/baseline_clean.sql"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "4. Сравнение..."
|
||||||
|
|
||||||
|
# Сравниваем количество таблиц
|
||||||
|
CURRENT_TABLES=$(grep -c "CREATE TABLE" "$TMP_DIR/current_schema.sql" || echo "0")
|
||||||
|
BASELINE_TABLES=$(grep -c "CREATE TABLE" "$BASELINE_FILE" || echo "0")
|
||||||
|
|
||||||
|
echo " Текущая БД: $CURRENT_TABLES таблиц"
|
||||||
|
echo " Baseline: $BASELINE_TABLES таблиц"
|
||||||
|
|
||||||
|
if [ "$CURRENT_TABLES" -ne "$BASELINE_TABLES" ]; then
|
||||||
|
echo -e "${YELLOW}⚠ Количество таблиц не совпадает${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✓ Количество таблиц совпадает${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Сравниваем количество индексов
|
||||||
|
CURRENT_INDEXES=$(grep -c "CREATE.*INDEX" "$TMP_DIR/current_schema.sql" || echo "0")
|
||||||
|
BASELINE_INDEXES=$(grep -c "CREATE.*INDEX" "$BASELINE_FILE" || echo "0")
|
||||||
|
|
||||||
|
echo " Текущая БД: $CURRENT_INDEXES индексов"
|
||||||
|
echo " Baseline: $BASELINE_INDEXES индексов"
|
||||||
|
|
||||||
|
if [ "$CURRENT_INDEXES" -ne "$BASELINE_INDEXES" ]; then
|
||||||
|
echo -e "${YELLOW}⚠ Количество индексов не совпадает${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✓ Количество индексов совпадает${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Проверяем наличие materialized view
|
||||||
|
CURRENT_MV=$(grep -c "CREATE MATERIALIZED VIEW" "$TMP_DIR/current_schema.sql" || echo "0")
|
||||||
|
BASELINE_MV=$(grep -c "CREATE MATERIALIZED VIEW" "$BASELINE_FILE" || echo "0")
|
||||||
|
|
||||||
|
echo " Текущая БД: $CURRENT_MV materialized views"
|
||||||
|
echo " Baseline: $BASELINE_MV materialized views"
|
||||||
|
|
||||||
|
if [ "$CURRENT_MV" -ne "$BASELINE_MV" ]; then
|
||||||
|
echo -e "${YELLOW}⚠ Количество materialized views не совпадает${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✓ Количество materialized views совпадает${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Проверка завершена ==="
|
||||||
|
echo ""
|
||||||
|
echo "Для детального сравнения выполните:"
|
||||||
|
echo " diff $TMP_DIR/current_schema.sql $BASELINE_FILE"
|
||||||
84
play-life-web/generate-icons.cjs
Normal file
84
play-life-web/generate-icons.cjs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// Скрипт для генерации базовых PWA иконок
|
||||||
|
// Требует: npm install sharp
|
||||||
|
|
||||||
|
const sharp = require('sharp');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const publicDir = path.join(__dirname, 'public');
|
||||||
|
|
||||||
|
// Создаем SVG шаблон для обычной иконки (со скругленными углами)
|
||||||
|
const createIconSVG = (size) => `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 100 100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#4f46e5;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
|
||||||
|
<g transform="translate(50, 47) scale(0.18, 0.136701675) translate(-488, -488)">
|
||||||
|
<path fill="white" stroke="white" stroke-width="8" d="M442 730V349h55c13 0 26-1 38 3 19 6 33 22 39 41 3 8 5 15 4 24a65 65 0 0 1-29 49c-2 2-6 3-7 6-1 5 4 13 6 17 3 12 4 24 4 36 10-2 19-8 27-13 57-36 69-114 33-168-16-24-45-44-73-49-22-4-46 0-69-1h-38c-9 0-18-1-26 3-9 3-17 15-18 24v358c0 11-1 23 4 33 9 21 31 18 50 18m28-254v254h105c14 0 33 3 46-4 18-11 16-40-3-48-9-4-21-2-31-2h-63V546c0-17 4-40-6-55-11-16-31-15-48-15z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Создаем SVG шаблон для maskable иконки (без скругления, контент в безопасной зоне 80%)
|
||||||
|
const createMaskableIconSVG = (size) => `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 100 100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#4f46e5;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#7c3aed;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="100" height="100" fill="url(#grad)"/>
|
||||||
|
<g transform="translate(50, 47) scale(0.15, 0.1139180625) translate(-501, -501)">
|
||||||
|
<path fill="white" stroke="white" stroke-width="8" d="M442 730V349h55c13 0 26-1 38 3 19 6 33 22 39 41 3 8 5 15 4 24a65 65 0 0 1-29 49c-2 2-6 3-7 6-1 5 4 13 6 17 3 12 4 24 4 36 10-2 19-8 27-13 57-36 69-114 33-168-16-24-45-44-73-49-22-4-46 0-69-1h-38c-9 0-18-1-26 3-9 3-17 15-18 24v358c0 11-1 23 4 33 9 21 31 18 50 18m28-254v254h105c14 0 33 3 46-4 18-11 16-40-3-48-9-4-21-2-31-2h-63V546c0-17 4-40-6-55-11-16-31-15-48-15z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function generateIcons() {
|
||||||
|
// Создаем базовые SVG
|
||||||
|
const baseSVG = createIconSVG(512);
|
||||||
|
const svgBuffer = Buffer.from(baseSVG);
|
||||||
|
|
||||||
|
const maskableSVG = createMaskableIconSVG(512);
|
||||||
|
const maskableSvgBuffer = Buffer.from(maskableSVG);
|
||||||
|
|
||||||
|
// Генерируем иконки разных размеров
|
||||||
|
const sizes = [
|
||||||
|
{ name: 'favicon-new.ico', size: 32 },
|
||||||
|
{ name: 'apple-touch-icon.png', size: 180 },
|
||||||
|
{ name: 'pwa-192x192.png', size: 192 },
|
||||||
|
{ name: 'pwa-512x512.png', size: 512 },
|
||||||
|
{ name: 'pwa-maskable-192x192.png', size: 192, maskable: true },
|
||||||
|
{ name: 'pwa-maskable-512x512.png', size: 512, maskable: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const icon of sizes) {
|
||||||
|
// Для maskable иконок используем специальный SVG с контентом в безопасной зоне
|
||||||
|
const sourceBuffer = icon.maskable ? maskableSvgBuffer : svgBuffer;
|
||||||
|
const image = sharp(sourceBuffer).resize(icon.size, icon.size);
|
||||||
|
|
||||||
|
const outputPath = path.join(publicDir, icon.name);
|
||||||
|
await image.png().toFile(outputPath);
|
||||||
|
console.log(`✓ Создана иконка: ${icon.name} (${icon.size}x${icon.size})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✓ Все иконки успешно созданы!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем наличие sharp
|
||||||
|
try {
|
||||||
|
require('sharp');
|
||||||
|
generateIcons().catch(console.error);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Для генерации иконок необходимо установить sharp:');
|
||||||
|
console.log('npm install sharp --save-dev');
|
||||||
|
console.log('\nИли создайте иконки вручную используя онлайн генераторы:');
|
||||||
|
console.log('- https://realfavicongenerator.net/');
|
||||||
|
console.log('- https://www.pwabuilder.com/imageGenerator');
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,9 +2,20 @@
|
|||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" href="/favicon.svg?v=3.18.1" type="image/svg+xml" />
|
||||||
|
<link rel="icon" href="/favicon-new.ico?v=3.18.1" sizes="32x32" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=3.18.1" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>PlayLife - Статистика</title>
|
|
||||||
|
<!-- PWA Meta Tags -->
|
||||||
|
<meta name="theme-color" content="#4f46e5" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="PlayLife" />
|
||||||
|
<meta name="screen-orientation" content="portrait" />
|
||||||
|
<meta name="description" content="Трекер продуктивности и изучения слов" />
|
||||||
|
|
||||||
|
<title>PlayLife</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy other API endpoints to backend
|
# Proxy admin panel to backend (must be before location /)
|
||||||
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|project/move|project/delete|message/post|webhook/|weekly_goals/setup|admin|admin\.html)$ {
|
location ^~ /admin {
|
||||||
proxy_pass http://backend:8080;
|
proxy_pass http://backend:8080;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
@@ -36,6 +36,67 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Proxy project endpoints to backend (must be before location /)
|
||||||
|
location ^~ /project/ {
|
||||||
|
proxy_pass http://backend: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|webhook/|weekly_goals/setup)$ {
|
||||||
|
proxy_pass http://backend: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 ^~ /uploads/ {
|
||||||
|
proxy_pass http://backend: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";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Статические HTML страницы (Terms и Privacy)
|
||||||
|
location = /terms {
|
||||||
|
try_files /terms.html =404;
|
||||||
|
add_header Cache-Control "public, max-age=3600";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /privacy {
|
||||||
|
try_files /privacy.html =404;
|
||||||
|
add_header Cache-Control "public, max-age=3600";
|
||||||
|
}
|
||||||
|
|
||||||
# Handle React Router (SPA)
|
# Handle React Router (SPA)
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
5250
play-life-web/package-lock.json
generated
5250
play-life-web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user