Compare commits

...

185 Commits

Author SHA1 Message Date
poignatov
6dbb0f8d90 Обновление gitea workflow: сборка всегда
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m11s
2026-01-27 19:40:09 +03:00
poignatov
6174475509 4.1.1: Отключен поворот экрана в PWA 2026-01-27 19:31:46 +03:00
poignatov
a611f05959 4.1.0: Оптимизация получения данных текущей недели
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m22s
2026-01-26 18:45:58 +03:00
poignatov
904b00f3f5 4.0.6: Включен поворот экрана в PWA
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 50s
2026-01-26 18:23:06 +03:00
poignatov
41aed56689 4.0.5: Добавлен covering index для reward_configs
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m28s
2026-01-26 18:20:52 +03:00
poignatov
6bea094b7f 4.0.4: Оптимизация SQL и отладка
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m14s
2026-01-26 18:17:56 +03:00
poignatov
99156d578a 4.0.3: Исправлен user_id в weekly_goals
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m34s
2026-01-26 18:05:20 +03:00
poignatov
08cc20265e Обновлен формат Telegram сообщений
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 10s
2026-01-25 16:51:30 +03:00
poignatov
ac0d67c2cf 4.0.2: Обновлен формат сообщений Telegram
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 59s
2026-01-25 16:48:17 +03:00
poignatov
daccb81763 4.0.1: Исправление кодировки пароля БД
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
2026-01-25 16:45:58 +03:00
poignatov
90643c504a 4.0.0: Исправлена обработка старых дампов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m19s
2026-01-25 16:41:50 +03:00
poignatov
b8ef59bfd1 Добавлено правило пуша и package.json
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11s
2026-01-25 16:01:41 +03:00
poignatov
34f162576b 3.28.4: Исправлена ошибка компиляции миграции
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
2026-01-25 15:54:06 +03:00
poignatov
fe3721a56f 3.28.3: Исправлена проблема с refresh token
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 54s
2026-01-25 15:46:24 +03:00
poignatov
ef1d6fb59a 3.28.2: Оптимизация загрузки деталей задачи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m44s
2026-01-25 15:28:37 +03:00
poignatov
47f47608bc Нормализация недельной нормы (3.28.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m29s
2026-01-24 14:31:00 +03:00
poignatov
dd4fa39d01 Версия 3.27.2
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m28s
2026-01-22 20:15:56 +03:00
poignatov
834e600886 Обновление Go до 1.24 для совместимости с chromedp
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 12s
2026-01-22 20:15:29 +03:00
poignatov
d1197e9428 Исправление CI сборки, версия 3.27.1
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 45s
2026-01-22 20:13:31 +03:00
poignatov
6fceafaa67 Добавлен headless Chrome fallback для OG метаданных
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 22s
2026-01-22 20:11:29 +03:00
poignatov
d569960ec1 Улучшена обработка метаданных ссылок
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-01-22 19:47:50 +03:00
poignatov
5a7c8b5d2f Исправлена передача имени словаря
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m4s
2026-01-22 19:32:52 +03:00
poignatov
e823312f0e Редизайн доски и дизайн-система кнопок
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 50s
2026-01-22 19:21:23 +03:00
poignatov
e7ef6caa41 Исправлен z-index модального окна
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 50s
2026-01-22 19:11:20 +03:00
poignatov
5c1a584925 Округление процентов в группах до целого
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
2026-01-22 19:05:14 +03:00
poignatov
fa231c2215 Исправление создания задачи-теста
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m10s
2026-01-22 19:03:14 +03:00
poignatov
0adf81cf6a Унификация расчета процентов с бэкендом
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m8s
2026-01-21 20:01:24 +03:00
poignatov
6578db6ec4 Перенос политики награждения в блок связи
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-01-21 19:09:56 +03:00
poignatov
d0d1cbd8cb Исправление сохранения reward_policy для задач-желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
2026-01-21 18:57:47 +03:00
poignatov
068794a98c Исправление создания желаний на досках
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m27s
2026-01-21 18:46:36 +03:00
poignatov
24be9fad27 Версия 3.25.0: исправлено добавление слов в словарь
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 52s
2026-01-20 22:16:08 +03:00
poignatov
e41f721106 Исправлены отступы в списке слов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 56s
2026-01-20 21:33:23 +03:00
poignatov
2626722af9 Поднята версия до 3.24.1 и улучшен UI модального окна задач
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s
2026-01-20 21:07:24 +03:00
poignatov
b2c95dcbab Обновление бэйджей задач и улучшения wishlist
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 53s
2026-01-20 21:02:22 +03:00
poignatov
5f2d610deb Версия 3.23.0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 54s
2026-01-20 20:55:53 +03:00
poignatov
eb708b057d Исправления задач-желаний и версия 3.22.0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 50s
2026-01-20 20:48:40 +03:00
poignatov
08d86eb5f5 Обновление интерфейса задач и версия 3.21.0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 49s
2026-01-20 20:40:38 +03:00
poignatov
2369661015 Поднимаем патч версию до 3.20.4
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 51s
2026-01-20 20:32:47 +03:00
poignatov
ae5c824592 Поднимаю версию до 3.20.3, поддержка >100% прогресса
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 51s
2026-01-20 20:21:33 +03:00
poignatov
c4b4ed2c3f Поднята версия до 3.20.2
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 51s
2026-01-20 20:13:26 +03:00
poignatov
5905711f7f Обновление лого и патч версия 3.20.1
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s
2026-01-20 18:09:09 +03:00
poignatov
efded0bcd2 Улучшения UI прогрессбаров и карточек
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m9s
2026-01-20 18:04:18 +03:00
poignatov
f884bd3339 Редизайн экрана проектов с круглыми прогрессами
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
2026-01-20 16:47:57 +03:00
Play Life Bot
9c97241d8d Оба типа иконок установлены на -3% выше центра
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-20 00:24:07 +03:00
Play Life Bot
007a3ae00b Оба типа иконок установлены на +3% ниже центра
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-20 00:19:55 +03:00
Play Life Bot
6cbb646a55 Обычные иконки опущены на 7% вниз от центра
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s
2026-01-20 00:18:06 +03:00
Play Life Bot
939c9ddc26 Обычные иконки смещены на 4% вверх от центра
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-20 00:16:25 +03:00
Play Life Bot
e132aaf79b Смещение логотипа вниз ещё на 4% для обоих видов иконок
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-20 00:15:06 +03:00
Play Life Bot
9299af7bad Отступ 3% вниз от центра для иконок
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-01-20 00:13:38 +03:00
Play Life Bot
50c53c8b63 Логотип смещён выше центра ещё на 5%
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-20 00:07:05 +03:00
Play Life Bot
70e4b53f21 Логотип смещён выше центра на 10%
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-20 00:03:22 +03:00
Play Life Bot
1188c899da Логотип уменьшен ещё на 5% и смещён ниже центра на 15%
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 51s
2026-01-19 23:59:50 +03:00
Play Life Bot
ae51060f97 Установлены точные размеры логотипа с уменьшением на 5% и корректировкой центрирования
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 49s
2026-01-19 23:54:46 +03:00
Play Life Bot
6bfb67544e Логотип уменьшен на 5% от исходного размера с корректировкой центрирования
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-19 23:50:32 +03:00
Play Life Bot
678ec6b422 Уменьшен логотип в иконках на 25% с корректировкой центрирования
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-19 23:46:57 +03:00
Play Life Bot
84efb42c54 Уменьшен логотип в иконках ещё на 10% с корректировкой центрирования
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-19 23:43:52 +03:00
Play Life Bot
53f7edddad Уменьшен логотип в иконках на 15% с проверкой центрирования
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 50s
2026-01-19 23:40:27 +03:00
Play Life Bot
a19b16a191 Исправлено центрирование логотипа в иконках
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 50s
2026-01-19 23:37:41 +03:00
Play Life Bot
17ea99837c Поднята патч версия до 3.16.6
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-01-19 23:36:44 +03:00
Play Life Bot
db712a4df6 Увеличен логотип в иконках в 11 раз
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-01-19 23:27:37 +03:00
Play Life Bot
a5452c42f9 Изменен favicon.ico на favicon-new.ico для обхода кэша браузера
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-19 23:22:50 +03:00
Play Life Bot
e77338fc76 Уменьшен логотип в иконках в 10 раз
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s
2026-01-19 23:20:51 +03:00
poignatov
6eccbbb469 Поднята минор версия до 3.16.0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-19 22:54:51 +03:00
poignatov
40a79eb8db Обновление иконок приложения и поднятие версии до 3.15.13
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s
2026-01-19 22:48:55 +03:00
poignatov
629b65144a Повышение минор версии до 3.15.12
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 55s
2026-01-19 22:34:07 +03:00
poignatov
6ce4bf44c9 Поднятие версии до 3.14.12, обновление названия приложения
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 52s
2026-01-19 22:27:30 +03:00
poignatov
d390fa4825 Исправлена сортировка желаний и версия 3.14.11
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 59s
2026-01-19 22:25:28 +03:00
poignatov
be2ae80226 Исправлена логика переноса задач с учётом часового пояса
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 56s
2026-01-19 22:06:46 +03:00
poignatov
cd61fe4766 Исправлена передача maxCards в тестах, версия 3.14.9
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-01-19 22:02:00 +03:00
poignatov
bea0e17133 Исправлен список задач после теста, версия 3.14.8
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-19 21:58:53 +03:00
poignatov
ab9022a585 Исправление дублирования чужих целей в wishlist и защита от редактирования
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m16s
2026-01-19 21:56:57 +03:00
poignatov
6d468d6967 Исправление отображения проектов в условиях
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
2026-01-19 13:07:17 +03:00
poignatov
e3c81a36de Исправлен доступ к желаниям на досках
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-01-14 18:51:03 +03:00
poignatov
c654a01116 Исправление отображения баллов в условиях желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 59s
2026-01-14 18:30:43 +03:00
poignatov
7200cdfda9 Исправления списка словарей: крестик и отступы
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-01-14 18:03:29 +03:00
poignatov
0e509dd61a Замена иконки словарей на стрелочку
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-01-14 17:57:20 +03:00
poignatov
1f423e1ed3 Исправление двойного скролла (3.14.1)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-01-14 17:55:10 +03:00
poignatov
f13838d91a Отключены подзадачи для задач-тестов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m27s
2026-01-14 17:50:14 +03:00
poignatov
f9928c6470 Доски желаний и политика награждения
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m0s
2026-01-13 22:35:01 +03:00
poignatov
5ebb55510e v3.12.2: удален лог
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-13 21:01:50 +03:00
poignatov
81e8ebdf66 Кнопка добавления в списке желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 51s
2026-01-13 21:00:20 +03:00
poignatov
ce7e0e584a Оптимизация wishlist: раздельные запросы и копирование
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m14s
2026-01-13 20:55:44 +03:00
poignatov
db3b2640a8 Рефакторинг тестов: интеграция с задачами
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
2026-01-13 18:22:02 +03:00
poignatov
cfd9339e48 Версия 3.10.8: исправление стилей счётчика
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-13 17:22:02 +03:00
poignatov
0783602fe8 Улучшена загрузка метаданных wishlist
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 56s
2026-01-13 17:19:00 +03:00
poignatov
22995b654d Исправление ошибки добавления проекта
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-13 17:07:20 +03:00
poignatov
b8ae0bb17a Исправление ошибки authFetch при переносе проекта
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-13 17:00:47 +03:00
poignatov
441f872f33 Сортировка групп задач и серый заголовок
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-01-13 16:58:03 +03:00
poignatov
0e53dfbdf7 Исправления формы желаний, очистка кода
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-13 16:52:08 +03:00
poignatov
a54c9983d4 Исправление скролла табов, версия 3.10.2
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-13 16:46:58 +03:00
poignatov
22bafd8c28 v3.10.1: упрощение заголовков отчётов
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m18s
2026-01-13 16:44:22 +03:00
poignatov
f56278c670 Уточнение: commit message на русском
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11s
2026-01-12 18:59:52 +03:00
poignatov
72a6a3caf9 Добавлена связь задач с желаниями
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 58s
2026-01-12 18:58:52 +03:00
poignatov
9fbe2081ed Add cursor rule for version bump and push
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 10s
2026-01-12 17:49:16 +03:00
poignatov
705eb2400e v3.9.5: Добавлена возможность копирования желаний, исправлена замена изображений
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s
2026-01-12 17:42:51 +03:00
poignatov
3cf3cd4edb fix(auth): improve token refresh with better logging and error handling
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 49s
- Add detailed logging for token refresh process
- Increase refresh timeout from 5s to 10s
- Log response body on refresh failure for diagnostics
- Verify tokens are present in refresh response
- Improve authFetch logging during retry

Version: 3.9.4
2026-01-12 17:05:19 +03:00
poignatov
b3a83e1e8f feat: замена period_type на start_date в wishlist, обновление UI формы условий
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
- Добавлена миграция 020 для замены period_type на start_date в score_conditions
- Обновлена функция подсчёта баллов: calculateProjectPointsFromDate вместо calculateProjectPointsForPeriod
- Добавлен компонент DateSelector для выбора даты начала подсчёта
- По умолчанию выбран тип условия 'Баллы'
- Переименованы опции: 'Баллы' и 'Задача'
- Версия: 3.9.3
2026-01-12 17:02:33 +03:00
poignatov
d368929a4a fix: add migrations folder to Docker image for wishlist tables
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 52s
- Added COPY play-life-backend/migrations /migrations to Dockerfile
- Fixed 'relation wishlist_items does not exist' error on production
- Bump version to 3.9.2
2026-01-11 21:36:28 +03:00
poignatov
f19ed9cb81 fix: исправлены конфликты стилей между экранами - выпадающие заголовки задач, многоточие в тестах и словарях (v3.9.1)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 48s
2026-01-11 21:31:42 +03:00
poignatov
e2059ef555 feat: добавлено автозаполнение полей wishlist из ссылки (v3.9.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
- Добавлен эндпоинт /api/wishlist/metadata для извлечения метаданных из URL
- Реализовано извлечение Open Graph тегов (title, image, description)
- Добавлена кнопка Pull для ручной загрузки информации из ссылки
- Автоматическое заполнение полей: название, цена, картинка
- Обновлена версия до 3.9.0
2026-01-11 21:12:26 +03:00
poignatov
932dba8682 Унификация отображения ошибок: LoadingError для загрузки, Toast для действий
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-01-11 15:51:28 +03:00
poignatov
8023fb9108 Bump version to 3.8.9: Add version display to profile
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-01-11 15:41:10 +03:00
poignatov
08f81887b0 Унифицировать отображение загрузки на всех экранах
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
- Добавлен единый стиль загрузки с лоадером и текстом 'Загрузка...'
- Центрирование по вертикали с учетом наличия табов
- Обновлены все 10 экранов: CurrentWeek, TestConfigSelection, TaskList, FullStatistics, WordList, TaskForm, TestWords, TodoistIntegration, TelegramIntegration, ProjectPriorityManager
- Версия: 3.8.8
2026-01-11 15:32:31 +03:00
poignatov
64d192796c fix: исправлен скроллинг нижнего бара при малом контенте (v3.8.7)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 49s
2026-01-11 15:11:15 +03:00
poignatov
f3a7d1c503 fix: исправлен расчет даты переноса задач с периодами повторения
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 54s
- Добавлена поддержка сокращенных форм единиц времени (mons, min, hrs, wks, yrs и т.д.)
- Исправлена обработка недель, которые PostgreSQL возвращает как дни (7 days вместо 1 week)
- Добавлено приведение repetition_period к тексту при чтении из БД
- Обновлена версия до 3.8.6
2026-01-11 15:09:32 +03:00
poignatov
29cf05a3c3 fix: обновлен расчет общего процента выполнения - отсутствующие группы приоритетов считаются как 100%
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 55s
- Изменена функция calculateOverallProgress: теперь всегда учитываются 3 группы приоритетов (1, 2, 0)
- Если группа отсутствует, она считается как 100%
- Общий процент всегда вычисляется как среднее из 3 групп: (группа1 + группа2 + группа0) / 3
- Изменения применяются для API и ежедневных Telegram отчетов
- Версия обновлена до 3.8.5
2026-01-11 15:00:20 +03:00
poignatov
a8cb7c2730 fix: перегенерированы maskable иконки с исправленным фоном (v3.8.4)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 43s
2026-01-11 14:56:36 +03:00
poignatov
374d03cdfd fix: исправлены maskable иконки для Android - убран прозрачный фон (v3.8.3)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-01-11 14:55:11 +03:00
poignatov
d5e4699bcf fix: добавлена maskable иконка 192x192 для Android (v3.8.2)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-10 21:52:43 +03:00
poignatov
5ccb214c04 fix: исправлен бэкстек при переключении между табами
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-01-10 21:49:20 +03:00
poignatov
11e0d0074c feat: добавлена поддержка PWA (v3.8.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
- Установлен vite-plugin-pwa для поддержки Progressive Web App
- Созданы иконки приложения для всех платформ (iOS, Android, Desktop)
- Настроен Service Worker с кэшированием статики и API данных
- Добавлен компонент PWAUpdatePrompt для уведомлений об обновлениях
- Обновлены конфигурации nginx для корректной работы Service Worker
- Добавлены PWA meta-теги в index.html
- Создан скрипт generate-icons.cjs для генерации иконок
2026-01-10 21:46:54 +03:00
poignatov
dde8858d7d Bump version to 3.7.0: Add next task date info in task completion dialog
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-01-10 19:27:36 +03:00
poignatov
cc7c6a905e v3.6.0: Улучшено модальное окно переноса задачи - нередактируемое поле с понятным форматированием даты
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 55s
2026-01-10 19:17:03 +03:00
poignatov
3d3fa13f41 fix: исправлена проблема с обновлением refresh token (race condition)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 50s
- Добавлена синхронизация параллельных refresh-запросов
- Исправлена проблема сброса авторизации на следующий день
- Версия обновлена до 3.5.7
2026-01-10 18:38:15 +03:00
poignatov
cbdcecea45 Fix: унифицировать горизонтальные отступы на всех экранах (v3.5.6)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 32s
2026-01-09 14:52:14 +03:00
poignatov
6cf4be65b2 fix: исправлена логика распределения слов в тесте и race condition
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 35s
- Переписан алгоритм redistributeWordsEvenly с жадным подходом
- Добавлена пост-обработка для исправления последовательных дубликатов
- Исключаемое слово (текущее) теперь корректно не появляется первым
- Исправлен race condition с cardsShown через использование ref
- Добавлена проверка на null/undefined слова в пуле

v3.5.5
2026-01-09 14:40:45 +03:00
poignatov
ef59781633 v3.5.4: Скрыть дату под заголовком для задач в общем списке
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-01-09 14:17:17 +03:00
poignatov
97c031eda4 v3.5.3: Убрана группировка бесконечных задач, добавлена иконка бесконечности, улучшен UI модального окна переноса
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-01-09 14:12:06 +03:00
poignatov
1097a84d06 Обновление модального окна переноса задачи (v3.5.2)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 39s
2026-01-09 13:51:50 +03:00
poignatov
b57b0bc901 Изменить логику выставления next_show_at: для repetition_period - сегодняшняя дата, для repetition_date - следующая подходящая дата
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
2026-01-09 13:36:10 +03:00
Play Life Bot
60a6f4deb4 feat: improved navigation and unified close buttons - version 3.5.0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 47s
2026-01-08 00:02:06 +03:00
poignatov
b1cfea22e6 Bump version to 3.4.2: improve date comparison in TaskList and enhance CI notifications
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-01-07 15:43:20 +03:00
poignatov
2f16876185 Bump version to 3.4.1 and add version logging on startup
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-01-07 15:31:40 +03:00
poignatov
b9133f60dc Bump version to 3.4.0 and add Telegram notifications to CI
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 36s
2026-01-07 15:06:58 +03:00
poignatov
db74626068 Fix regex panic in task completion (v3.3.1)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 41s
- Replace unsupported lookahead regex with manual string replacement
- Fix 502 error when completing tasks
- Update version to 3.3.1
2026-01-06 16:50:11 +03:00
poignatov
b41f6e7cdc Add repetition_date support for tasks (v3.3.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 42s
- Add repetition_date field to tasks table (migration 018)
- Support pattern-based repetition: day of week, day of month, specific date
- Add 'Через'/'Каждое' mode selector in task form
- Auto-calculate next_show_at from repetition_date on create/complete
- Show calculated next date in postpone dialog for repetition_date tasks
- Update version to 3.3.0
2026-01-06 16:41:54 +03:00
poignatov
508355dcb3 feat: добавлена функциональность откладывания задач (next_show_at)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-01-06 15:56:52 +03:00
poignatov
1da35aaea4 fix: исправлен отступ между словами в списке перед и после теста
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 27s
2026-01-06 15:31:43 +03:00
poignatov
d9db42a598 v3.1.4: Улучшено равномерное распределение карточек в тесте
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 28s
- Добавлен учёт текущей карточки при перераспределении (не показывается следующей)
- Исправлен алгоритм равномерного распределения для предотвращения подряд идущих одинаковых карточек
- Исправлен правый счётчик: показывает текущий размер пула + показанные карточки (не больше maxCards)
2026-01-06 15:30:30 +03:00
poignatov
28a45ab81e fix: использовать приоритет только из weekly_goals, без fallback на projects.priority (v3.1.3)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 40s
2026-01-06 15:09:58 +03:00
poignatov
9e5790f70e fix: очистка формы после добавления задачи (v3.1.2)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 28s
2026-01-06 15:02:55 +03:00
poignatov
7df258da15 feat: добавлена поддержка шаблонов $0 и \$0 для наград в задачах (v3.1.1)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 44s
2026-01-06 15:00:42 +03:00
poignatov
0ea531889d v3.1.0: Оптимизация загрузки списка задач - все данные в одном запросе, добавлены индикаторы подзадач и прогрессии
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 40s
2026-01-06 14:54:37 +03:00
poignatov
28d8148665 fix: убрать текст 'Выполнение...' из кнопки выполнения задачи с длительностью
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 29s
2026-01-06 14:38:16 +03:00
poignatov
a7bc912db3 v3.0.0: Добавлен обратный поворот карточки в тесте по клику
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 28s
2026-01-06 14:34:28 +03:00
poignatov
647c549ec9 feat: добавлен раздел 'Бесконечные' для задач с периодичностью 0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-01-06 14:31:00 +03:00
poignatov
a6065d7ff1 fix: исправлен импорт TaskForm с явным расширением .jsx, версия 2.9.1
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 39s
2026-01-04 19:42:29 +03:00
poignatov
79430ba7f0 v2.9.0: Улучшения экрана списка задач - оптимизация загрузки, toast уведомления, исправление центрирования
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 44s
2026-01-04 19:37:59 +03:00
poignatov
6d7d59d2ae Add init/run scripts and Cursor/VSCode configurations
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
- Add init.sh: initializes app with Docker, creates prod backup, restores to local DB
- Add run.sh: restarts all containers
- Update restore-db.sh: auto-selects latest dump, terminates active connections before restore
- Add .cursor/commands.json: Cursor commands (init, run, backupFromProd, restoreToLocal)
- Add .vscode/tasks.json: VSCode tasks for running scripts
- Add .vscode/launch.json: launch configurations for restarting server
- Remove play-life-backend/env.example (unified .env in root)
2026-01-03 17:08:42 +03:00
poignatov
2b9a024d3e fix: исправлен расчет общего процента выполнения в ежедневном отчете и унифицирована логика
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-03 16:13:28 +03:00
Play Life Bot
4767f5975c Revert add dictionary button color to black (v2.8.5)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 11s
2026-01-02 18:27:25 +03:00
Play Life Bot
bacb605a0c Change add dictionary button color to white (v2.8.4)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 24s
2026-01-02 18:24:39 +03:00
Play Life Bot
3bdad682b3 Improve scroll handling in project priorities list (v2.8.3)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 22s
2026-01-02 18:11:57 +03:00
Play Life Bot
01e8b3468c Fix scroll calculation for project priorities list (v2.8.2)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 22s
2026-01-02 18:05:02 +03:00
Play Life Bot
ac34f480be Fix scroll issue in project priorities list (v2.8.1)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 23s
2026-01-02 17:52:54 +03:00
Play Life Bot
27befeb92b Refactor group 2 word selection: use (failure+1)/(success+1) ratio, bump version to 2.8.0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-01-02 16:50:40 +03:00
Play Life Bot
9e50a718d8 chore: bump version to 2.7.4
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 22s
2026-01-02 16:41:20 +03:00
Play Life Bot
08c5422d35 fix: отображение блока процента выполнения даже при отсутствии проектов (v2.7.3)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 16:30:08 +03:00
Play Life Bot
bf539c6e91 Fix: Improve auth persistence on container restart - distinguish network errors from auth errors (v2.7.3)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 21s
2026-01-02 16:19:54 +03:00
Play Life Bot
2326a774ad Fix: Prevent auth state loss on container restart (v2.7.2)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 21s
2026-01-02 16:15:42 +03:00
Play Life Bot
1cfaaa9506 chore: bump version to 2.7.1
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 19s
2026-01-02 16:11:48 +03:00
Play Life Bot
ecc61c2a5f fix: add project/create endpoint to nginx config
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 16:11:35 +03:00
Play Life Bot
a5ce0de236 feat: добавлена возможность создания проектов через UI - версия 2.7.0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 38s
2026-01-02 16:09:16 +03:00
Play Life Bot
ccb365c95c Remove integration icons from profile list
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 28s
2026-01-02 16:08:32 +03:00
Play Life Bot
1b2c79a8f2 Fix profile layout: align list items and add integration icons
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 30s
2026-01-02 16:04:22 +03:00
Play Life Bot
d012f39be8 feat: увеличить время жизни access token до 24 часов, сделать refresh token бессрочным
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-01-02 16:00:54 +03:00
Play Life Bot
8b66e5fd6e Remove emoji from logout button (v2.4.1)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 21s
2026-01-02 15:59:24 +03:00
Play Life Bot
4ca6eb4fd5 Remove emojis from integrations list in profile (v2.4.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 20s
2026-01-02 15:55:56 +03:00
Play Life Bot
3a256dc290 Remove Floor from priority 0 calculation formula, bump version to 2.3.0
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-01-02 15:52:15 +03:00
Play Life Bot
38f640e38e chore: bump version to 2.2.5
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-01-02 15:46:49 +03:00
Play Life Bot
8f7acee60c fix: handle string ID in Todoist user info response
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 15:46:20 +03:00
Play Life Bot
bcea4b2bf5 fix: improve Todoist user info API call with better logging and error handling
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 15:45:54 +03:00
Play Life Bot
bd6dfd968c chore: bump version to 2.2.4
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-01-02 15:43:28 +03:00
Play Life Bot
7547058507 fix: use App.jwtSecret instead of env for OAuth state token
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 15:42:59 +03:00
Play Life Bot
53e3f23422 chore: bump version to 2.2.3
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 41s
2026-01-02 15:40:34 +03:00
Play Life Bot
713f6020f6 fix: use authFetch for Todoist OAuth connect to send auth header
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 15:40:06 +03:00
Play Life Bot
72002a2b4f chore: bump version to 2.2.2
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 32s
2026-01-02 15:37:56 +03:00
Play Life Bot
bc73160e1a fix: remove duplicate code in todoistWebhookHandler
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 15:37:40 +03:00
Play Life Bot
9206b73b33 chore: bump version to 2.2.1
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 27s
2026-01-02 15:36:06 +03:00
Play Life Bot
e74c4cf599 fix: use correct jwt/v5 API for OAuth state token
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-02 15:35:37 +03:00
Play Life Bot
a7128703fe feat: refactor Todoist integration to single app with OAuth
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 32s
- Single webhook URL for all users

- OAuth authorization flow

- Removed individual webhook tokens

- User identification by todoist_user_id

- Added OAuth endpoints: connect, callback, status, disconnect

- Updated frontend with OAuth flow

- DB migration 013: removed webhook_token, added todoist_user_id, todoist_email, access_token

Version: 2.2.0
2026-01-02 15:34:01 +03:00
Play Life Bot
8ba7e8fd45 feat: Переделка Telegram интеграции на единого бота (v2.1.0)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 54s
- Единый бот для всех пользователей (токен из .env)
- Deep link для подключения через /start команду
- Отдельная таблица todoist_integrations для Todoist webhook
- Персональные отчеты для каждого пользователя
- Автоматическое применение миграции 012 при старте
- Обновлен Frontend: кнопка подключения вместо поля ввода токена
2026-01-02 14:47:51 +03:00
poignatov
4df054536a Fix dictionary_id type error by removing COALESCE from prepared statement (v2.0.8)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-01-01 19:33:29 +03:00
poignatov
cf4d5d40c3 Fix dictionary_id type mismatch in addWordsHandler (v2.0.7)
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-01-01 19:25:04 +03:00
poignatov
d96bb2ce8d v2.0.6: Fix addWords handler - add user_id and improve error handling
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 40s
- Added user_id to words insertion (was missing, causing 500 errors)
- Fixed default dictionary query (removed incorrect id=0 condition)
- Added dictionary ownership validation before inserting words
- Added comprehensive logging for debugging addWords operations
2026-01-01 19:13:37 +03:00
poignatov
6f77f0643c v2.0.5: Fix transaction errors and webhook parsing
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 40s
- Fixed transaction abort error in insertMessageData (replaced ON CONFLICT with SELECT check)
- Fixed double body reading in setupTelegramWebhook (use json.Unmarshal)
- Fixed Todoist webhook JSON parsing (use json.Unmarshal from bodyBytes)
- Improved error handling in webhook responses
- Added user_id to nodes insertion
2026-01-01 18:57:30 +03:00
poignatov
edc29fbd97 v2.0.4: Fix webhook error handling and logging
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 41s
- Webhooks now return 200 OK even on errors (to prevent retries)
- Improved error handling with proper JSON responses
- Enhanced logging for webhook debugging
- Supervisor logs now visible in docker logs (stdout/stderr)
- Fixed TodoistIntegration error display in UI
2026-01-01 18:50:55 +03:00
poignatov
7704de334c v2.0.3: Webhook user identification by URL token
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 42s
- Added webhook_token to telegram_integrations
- Webhooks now identify users by token in URL (/webhook/telegram/{token}, /webhook/todoist/{token})
- Webhook automatically configured for all users on backend startup
- Migration 011: Add webhook_token column
2026-01-01 18:38:28 +03:00
poignatov
ad1caceda0 v2.0.2: Update version
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s
2026-01-01 18:25:20 +03:00
poignatov
91d9b52524 fix: downgrade golang.org/x/crypto to v0.28.0 for Go 1.21 compatibility
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-01 18:24:49 +03:00
poignatov
914998980e v2.0.1: Fix Go version for Docker build
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 12s
2026-01-01 18:24:00 +03:00
poignatov
b709192447 fix: go version 1.21 for Docker build
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 6s
2026-01-01 18:23:10 +03:00
poignatov
4a06ceb7f6 v2.0.0: Multi-user authentication with JWT
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 16s
Features:
- User registration and login with JWT tokens
- All data is now user-specific (multi-tenancy)
- Profile page with integrations and logout
- Automatic migration of existing data to first user

Backend changes:
- Added users and refresh_tokens tables
- Added user_id to all data tables (projects, entries, nodes, dictionaries, words, progress, configs, telegram_integrations, weekly_goals)
- JWT authentication middleware
- claimOrphanedData() for data migration

Frontend changes:
- AuthContext for state management
- Login/Register forms
- Profile page (replaced Integrations)
- All API calls use authFetch with Bearer token

Migration notes:
- On first deploy, backend automatically adds user_id columns
- First user to login claims all existing data
2026-01-01 18:21:18 +03:00
poignatov
6015b62d29 Исправлена логика dump-db.sh для работы с удаленными хостами
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 15s
2026-01-01 17:04:10 +03:00
poignatov
f8aa81f963 Bump version to 1.1.1: Fix Telegram webhook handling and chat_id saving
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m33s
- Fix TelegramUpdate struct to handle message and edited_message properly
- Fix chat_id saving for messages without text
- Add comprehensive logging for webhook registration
- Improve error handling and diagnostics
2025-12-31 19:39:01 +03:00
134 changed files with 32702 additions and 3075 deletions

33
.cursor/commands.json Normal file
View 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}"
}
]
}

View File

@@ -0,0 +1,8 @@
---
description: "Запрет доработок старых миграций"
alwaysApply: true
---
**ВАЖНО:** Если ты меняешь структуру базы данных - напиши НОВУЮ миграцию.
НИ В КОЕМ СЛУЧАЕ не меняй старые миграции, можно добавлять только новые.
Старой миграцией считается та что была уже ранее закомичена

View 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` в корне проекта.

View 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
View 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

View File

@@ -21,10 +21,14 @@ 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")
if [ "$CUR" != "$PREV" ]; then if [ "$CUR" != "$PREV" ]; then
echo "changed=true" >> $GITHUB_OUTPUT echo "changed=true" >> $GITHUB_OUTPUT
else else
@@ -32,25 +36,86 @@ 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
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 }}*
Сборка: ✅
Публикация: ⏭️
- name: Send Telegram notification (publish success)
if: success() && steps.version_check.outputs.changed == 'true'
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 }}*
Сборка: ✅
Публикация: ✅
- name: Send Telegram notification (failure)
if: 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 }}*
Сборка: ❌
Публикация: ❌

3
.gitignore vendored
View File

@@ -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
View File

@@ -0,0 +1,4 @@
{
"version": "0.2.0",
"configurations": []
}

82
.vscode/tasks.json vendored Normal file
View 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": "Восстановление базы данных из самого свежего дампа в локальную базу (автоматически выбирает последний дамп)"
}
]
}

View File

@@ -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
View 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
View 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

View File

@@ -1,2 +1 @@
1.1.0 4.1.1

View File

@@ -41,6 +41,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

View File

@@ -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
# Сжимаем дамп # Сжимаем дамп

View File

@@ -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,35 @@ 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=
# ============================================
# 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
View 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

View File

@@ -50,7 +50,7 @@ server {
} }
# Proxy other API endpoints to backend # Proxy other API endpoints to backend
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|project/move|project/delete|message/post|weekly_goals/setup|admin|admin\.html)$ { location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|project/move|project/delete|project/create|message/post|weekly_goals/setup|admin|admin\.html)$ {
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,31 @@ server {
proxy_cache_bypass $http_upgrade; 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
View 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"
}
}

View File

@@ -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

View 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`
Приложение автоматически проверяет оба пути.

View 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 "Проверьте работу приложения"
```

View 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 "Теперь приложение будет автоматически применять новые миграции при запуске."

View File

@@ -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

View File

@@ -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
) )

View File

@@ -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

View 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

View 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);

View File

@@ -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.

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
-- Rollback migration: Remove covering index for reward_configs
DROP INDEX IF EXISTS idx_reward_configs_task_id_covering;

View File

@@ -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.';

View File

@@ -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.';

View File

@@ -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.';

View File

@@ -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

View File

@@ -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).

View 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})';

View File

@@ -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 при первом подключении';

View File

@@ -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)';

View File

@@ -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.';

View 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';

View File

@@ -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)';

View 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)';

View 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.';

View 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';

View File

@@ -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;

View File

@@ -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.';

View File

@@ -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.';

View 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';

View 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';

View File

@@ -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.';

View File

@@ -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);

View File

@@ -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.';

View File

@@ -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.';

View File

@@ -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.';

View 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>` для создания новых миграций.

View 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 ""

View 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"

View 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');
}

View File

@@ -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>

View File

@@ -24,7 +24,7 @@ server {
} }
# Proxy other API endpoints to backend # Proxy other API endpoints to backend
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 ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|project/move|project/delete|project/create|message/post|webhook/|weekly_goals/setup|admin|admin\.html)$ {
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,30 @@ server {
proxy_cache_bypass $http_upgrade; 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";
}
# Handle React Router (SPA) # Handle React Router (SPA)
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "1.1.0", "version": "4.1.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -14,7 +14,9 @@
"chart.js": "^4.4.0", "chart.js": "^4.4.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0" "react-circular-progressbar": "^2.2.0",
"react-dom": "^18.2.0",
"react-easy-crop": "^5.5.6"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
@@ -22,7 +24,9 @@
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"postcss": "^8.4.32", "postcss": "^8.4.32",
"sharp": "^0.34.5",
"tailwindcss": "^3.3.6", "tailwindcss": "^3.3.6",
"vite": "^5.0.8" "vite": "^5.0.8",
"vite-plugin-pwa": "^1.2.0"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 B

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" 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, 50) scale(0.16, 0.12) translate(-512, -512)">
<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>

After

Width:  |  Height:  |  Size: 889 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,222 +0,0 @@
.add-config {
padding-left: 1rem;
padding-right: 1rem;
}
@media (min-width: 768px) {
.add-config {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
.add-config h2 {
margin-top: 2rem;
margin-bottom: 1rem;
color: #2c3e50;
font-size: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #2c3e50;
font-weight: 500;
}
.form-input,
.form-textarea {
width: 100%;
padding: 0.75rem;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.2s;
font-family: inherit;
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: #3498db;
}
.submit-button {
background-color: #3498db;
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
width: 100%;
}
.submit-button:hover:not(:disabled) {
background-color: #2980b9;
}
.submit-button:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
.message {
margin-top: 1rem;
padding: 1rem;
border-radius: 4px;
font-weight: 500;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.stepper-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.stepper-button {
background-color: #3498db;
color: white;
border: none;
width: 40px;
height: 40px;
border-radius: 8px;
font-size: 1.5rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stepper-button:hover:not(:disabled) {
background-color: #2980b9;
transform: translateY(-1px);
}
.stepper-button:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
opacity: 0.6;
}
.stepper-input {
flex: 1;
padding: 0.75rem;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 1rem;
text-align: center;
transition: border-color 0.2s;
font-family: inherit;
}
.stepper-input:focus {
outline: none;
border-color: #3498db;
}
.close-x-button {
position: fixed;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.9);
border: none;
font-size: 1.5rem;
color: #7f8c8d;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s, color 0.2s;
z-index: 1600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.close-x-button:hover {
background-color: #ffffff;
color: #2c3e50;
}
.dictionaries-hint {
font-size: 0.875rem;
color: #7f8c8d;
margin-bottom: 0.75rem;
font-style: italic;
}
.dictionaries-checkbox-list {
display: flex;
flex-direction: column;
gap: 0;
max-height: 200px;
overflow-y: auto;
padding: 0.5rem;
border: 2px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
}
.dictionary-checkbox-label {
display: flex;
align-items: center;
cursor: pointer;
padding: 0.5rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.dictionary-checkbox-label:hover {
background-color: #e8f4f8;
}
.dictionary-checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
min-width: 18px;
min-height: 18px;
margin: 0;
margin-right: 0.75rem;
padding: 0;
cursor: pointer;
accent-color: #3498db;
flex-shrink: 0;
align-self: center;
vertical-align: middle;
}
.dictionary-checkbox-label span {
color: #2c3e50;
font-size: 0.95rem;
line-height: 18px;
display: inline-block;
vertical-align: middle;
}

View File

@@ -1,344 +0,0 @@
import React, { useState, useEffect } from 'react'
import './AddConfig.css'
const API_URL = '/api'
function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
const [name, setName] = useState('')
const [tryMessage, setTryMessage] = useState('')
const [wordsCount, setWordsCount] = useState('10')
const [maxCards, setMaxCards] = useState('')
const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false)
const [dictionaries, setDictionaries] = useState([])
const [selectedDictionaryIds, setSelectedDictionaryIds] = useState([])
const [loadingDictionaries, setLoadingDictionaries] = useState(false)
// Load dictionaries
useEffect(() => {
const loadDictionaries = async () => {
setLoadingDictionaries(true)
try {
const response = await fetch(`${API_URL}/test-configs-and-dictionaries`)
if (!response.ok) {
throw new Error('Ошибка при загрузке словарей')
}
const data = await response.json()
setDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
} catch (err) {
console.error('Failed to load dictionaries:', err)
} finally {
setLoadingDictionaries(false)
}
}
loadDictionaries()
}, [])
// Load selected dictionaries when editing
useEffect(() => {
const loadSelectedDictionaries = async () => {
if (initialEditingConfig?.id) {
try {
const response = await fetch(`${API_URL}/configs/${initialEditingConfig.id}/dictionaries`)
if (response.ok) {
const data = await response.json()
setSelectedDictionaryIds(Array.isArray(data.dictionary_ids) ? data.dictionary_ids : [])
}
} catch (err) {
console.error('Failed to load selected dictionaries:', err)
}
} else {
setSelectedDictionaryIds([])
}
}
loadSelectedDictionaries()
}, [initialEditingConfig])
useEffect(() => {
if (initialEditingConfig) {
setName(initialEditingConfig.name)
setTryMessage(initialEditingConfig.try_message)
setWordsCount(String(initialEditingConfig.words_count))
setMaxCards(initialEditingConfig.max_cards ? String(initialEditingConfig.max_cards) : '')
} else {
// Сбрасываем состояние при открытии в режиме добавления
setName('')
setTryMessage('')
setWordsCount('10')
setMaxCards('')
setMessage('')
setSelectedDictionaryIds([])
}
}, [initialEditingConfig])
// Сбрасываем состояние при размонтировании компонента
useEffect(() => {
return () => {
setName('')
setTryMessage('')
setWordsCount('10')
setMaxCards('')
setMessage('')
setLoading(false)
}
}, [])
const handleSubmit = async (e) => {
e.preventDefault()
setMessage('')
setLoading(true)
if (!name.trim()) {
setMessage('Имя обязательно для заполнения.')
setLoading(false)
return
}
try {
const url = initialEditingConfig
? `${API_URL}/configs/${initialEditingConfig.id}`
: `${API_URL}/configs`
const method = initialEditingConfig ? 'PUT' : 'POST'
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name.trim(),
try_message: tryMessage.trim() || '',
words_count: wordsCount === '' ? 0 : parseInt(wordsCount) || 0,
max_cards: maxCards === '' ? null : parseInt(maxCards) || null,
dictionary_ids: selectedDictionaryIds.length > 0 ? selectedDictionaryIds : undefined,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage = errorData.message || response.statusText || `Ошибка при ${initialEditingConfig ? 'обновлении' : 'создании'} конфигурации`
throw new Error(errorMessage)
}
if (!initialEditingConfig) {
setName('')
setTryMessage('')
setWordsCount('10')
setMaxCards('')
}
// Navigate back immediately
onNavigate?.('test-config')
} catch (error) {
setMessage(`Ошибка: ${error.message}`)
} finally {
setLoading(false)
}
}
const getNumericValue = () => {
return wordsCount === '' ? 0 : parseInt(wordsCount) || 0
}
const getMaxCardsNumericValue = () => {
return maxCards === '' ? 0 : parseInt(maxCards) || 0
}
const handleDecrease = () => {
const numValue = getNumericValue()
if (numValue > 0) {
setWordsCount(String(numValue - 1))
}
}
const handleIncrease = () => {
const numValue = getNumericValue()
setWordsCount(String(numValue + 1))
}
const handleMaxCardsDecrease = () => {
const numValue = getMaxCardsNumericValue()
if (numValue > 0) {
setMaxCards(String(numValue - 1))
} else {
setMaxCards('')
}
}
const handleMaxCardsIncrease = () => {
const numValue = getMaxCardsNumericValue()
const newValue = numValue + 1
setMaxCards(String(newValue))
}
const handleClose = () => {
// Сбрасываем состояние при закрытии
setName('')
setTryMessage('')
setWordsCount('10')
setMaxCards('')
setMessage('')
onNavigate?.('test-config')
}
return (
<div className="add-config">
<button className="close-x-button" onClick={handleClose}>
</button>
<h2>Конфигурация теста</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Имя</label>
<input
id="name"
type="text"
className="form-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Название конфига"
required
/>
</div>
<div className="form-group">
<label htmlFor="tryMessage">Сообщение (необязательно)</label>
<textarea
id="tryMessage"
className="form-textarea"
value={tryMessage}
onChange={(e) => setTryMessage(e.target.value)}
placeholder="Сообщение которое будет отправлено в play-life при прохождении теста"
rows={4}
/>
</div>
<div className="form-group">
<label htmlFor="wordsCount">Кол-во слов</label>
<div className="stepper-container">
<button
type="button"
className="stepper-button"
onClick={handleDecrease}
disabled={getNumericValue() <= 0}
>
</button>
<input
id="wordsCount"
type="number"
className="stepper-input"
value={wordsCount}
onChange={(e) => {
const inputValue = e.target.value
if (inputValue === '') {
setWordsCount('')
} else {
const numValue = parseInt(inputValue)
if (!isNaN(numValue) && numValue >= 0) {
setWordsCount(inputValue)
}
}
}}
min="0"
required
/>
<button
type="button"
className="stepper-button"
onClick={handleIncrease}
>
+
</button>
</div>
</div>
<div className="form-group">
<label htmlFor="maxCards">Макс. кол-во карточек (необязательно)</label>
<div className="stepper-container">
<button
type="button"
className="stepper-button"
onClick={handleMaxCardsDecrease}
disabled={getMaxCardsNumericValue() <= 0}
>
</button>
<input
id="maxCards"
type="number"
className="stepper-input"
value={maxCards}
onChange={(e) => {
const inputValue = e.target.value
if (inputValue === '') {
setMaxCards('')
} else {
const numValue = parseInt(inputValue)
if (!isNaN(numValue) && numValue >= 0) {
setMaxCards(inputValue)
}
}
}}
min="0"
/>
<button
type="button"
className="stepper-button"
onClick={handleMaxCardsIncrease}
>
+
</button>
</div>
</div>
<div className="form-group">
<label htmlFor="dictionaries">Словари (необязательно)</label>
<div className="dictionaries-hint">
Если не выбрано ни одного словаря, будут использоваться все словари
</div>
{loadingDictionaries ? (
<div>Загрузка словарей...</div>
) : (
<div className="dictionaries-checkbox-list">
{dictionaries.map((dict) => (
<label key={dict.id} className="dictionary-checkbox-label">
<input
type="checkbox"
checked={selectedDictionaryIds.includes(dict.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedDictionaryIds([...selectedDictionaryIds, dict.id])
} else {
setSelectedDictionaryIds(selectedDictionaryIds.filter(id => id !== dict.id))
}
}}
/>
<span>{dict.name} ({dict.wordsCount})</span>
</label>
))}
</div>
)}
</div>
<button
type="submit"
className="submit-button"
disabled={loading || !name.trim() || getNumericValue() === 0}
>
{loading ? (initialEditingConfig ? 'Обновление...' : 'Создание...') : (initialEditingConfig ? 'Обновить конфигурацию' : 'Создать конфигурацию')}
</button>
</form>
{message && (
<div className={`message ${message.includes('Ошибка') ? 'error' : 'success'}`}>
{message}
</div>
)}
</div>
)
}
export default AddConfig

View File

@@ -1,15 +1,49 @@
import React, { useState } from 'react' import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './AddWords.css' import './AddWords.css'
const API_URL = '/api' const API_URL = '/api'
function AddWords({ onNavigate, dictionaryId, dictionaryName }) { function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
const { authFetch } = useAuth()
const [markdownText, setMarkdownText] = useState('') const [markdownText, setMarkdownText] = useState('')
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [currentDictionaryName, setCurrentDictionaryName] = useState(dictionaryName || '')
const [dictionaryLoading, setDictionaryLoading] = useState(false)
// Fetch dictionary name if not provided and dictionaryId exists
useEffect(() => {
if (dictionaryName) {
setCurrentDictionaryName(dictionaryName)
} else if (dictionaryId) {
fetchDictionaryName(dictionaryId)
}
}, [dictionaryId, dictionaryName])
const fetchDictionaryName = async (dictId) => {
if (!dictId) return
setDictionaryLoading(true)
try {
const response = await authFetch(`${API_URL}/dictionaries`)
if (!response.ok) {
throw new Error('Ошибка при загрузке словарей')
}
const dictionaries = await response.json()
const dict = dictionaries.find(d => d.id === dictId)
if (dict) {
setCurrentDictionaryName(dict.name)
}
} catch (err) {
console.error('Error fetching dictionary name:', err)
} finally {
setDictionaryLoading(false)
}
}
// Hide add button if dictionary name is not set // Hide add button if dictionary name is not set
const canAddWords = dictionaryName && dictionaryName.trim() !== '' const canAddWords = currentDictionaryName && currentDictionaryName.trim() !== ''
const parseMarkdownTable = (text) => { const parseMarkdownTable = (text) => {
const lines = text.split('\n') const lines = text.split('\n')
@@ -81,7 +115,7 @@ function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
dictionary_id: dictionaryId !== undefined && dictionaryId !== null ? dictionaryId : undefined dictionary_id: dictionaryId !== undefined && dictionaryId !== null ? dictionaryId : undefined
})) }))
const response = await fetch(`${API_URL}/words`, { const response = await authFetch(`${API_URL}/words`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -108,6 +142,20 @@ function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
onNavigate?.('words', dictionaryId !== undefined && dictionaryId !== null ? { dictionaryId } : {}) onNavigate?.('words', dictionaryId !== undefined && dictionaryId !== null ? { dictionaryId } : {})
} }
// Show loading state while fetching dictionary name
if (dictionaryLoading) {
return (
<div className="add-words">
<div className="fixed inset-0 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
</div>
)
}
return ( return (
<div className="add-words"> <div className="add-words">
<button className="close-x-button" onClick={handleClose}> <button className="close-x-button" onClick={handleClose}>

View File

@@ -0,0 +1,132 @@
.board-form {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
position: relative;
padding-bottom: 5rem;
}
.board-form h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 1.5rem 0;
}
.form-card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.form-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.form-section h3 {
font-size: 1rem;
font-weight: 600;
color: #374151;
margin: 0 0 1rem 0;
}
/* Toggle switch */
.toggle-field {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
}
.toggle-field input[type="checkbox"] {
display: none;
}
.toggle-slider {
position: relative;
width: 48px;
height: 26px;
background: #d1d5db;
border-radius: 13px;
transition: background 0.2s;
flex-shrink: 0;
}
.toggle-slider::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.toggle-field input:checked + .toggle-slider {
background: #6366f1;
}
.toggle-field input:checked + .toggle-slider::after {
transform: translateX(22px);
}
.toggle-label {
font-size: 0.95rem;
color: #374151;
}
/* Invite link section */
.invite-link-section {
margin-top: 1rem;
}
.invite-url-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.invite-url-input {
flex: 1;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.9rem;
background: #f9fafb;
color: #374151;
font-family: monospace;
}
.copy-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
background: #6366f1;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1.1rem;
transition: background 0.2s;
flex-shrink: 0;
}
.copy-btn:hover {
background: #4f46e5;
}
.invite-hint {
margin-top: 8px;
font-size: 0.85rem;
color: #6b7280;
}

View File

@@ -0,0 +1,286 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import BoardMembers from './BoardMembers'
import Toast from './Toast'
import SubmitButton from './SubmitButton'
import DeleteButton from './DeleteButton'
import './Buttons.css'
import './BoardForm.css'
function BoardForm({ boardId, onNavigate, onSaved }) {
const { authFetch } = useAuth()
const [name, setName] = useState('')
const [inviteEnabled, setInviteEnabled] = useState(false)
const [inviteURL, setInviteURL] = useState('')
const [loading, setLoading] = useState(false)
const [loadingBoard, setLoadingBoard] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [copied, setCopied] = useState(false)
const [toastMessage, setToastMessage] = useState(null)
const isEdit = !!boardId
useEffect(() => {
if (boardId) {
fetchBoard()
}
}, [boardId])
const fetchBoard = async () => {
setLoadingBoard(true)
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}`)
if (res.ok) {
const data = await res.json()
setName(data.name)
setInviteEnabled(data.invite_enabled)
setInviteURL(data.invite_url || '')
} else {
setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка загрузки', type: 'error' })
} finally {
setLoadingBoard(false)
}
}
const handleSave = async () => {
if (!name.trim()) {
setToastMessage({ text: 'Введите название доски', type: 'error' })
return
}
setLoading(true)
try {
const url = boardId
? `/api/wishlist/boards/${boardId}`
: '/api/wishlist/boards'
const res = await authFetch(url, {
method: boardId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
invite_enabled: inviteEnabled
})
})
if (res.ok) {
const data = await res.json()
if (data.invite_url) {
setInviteURL(data.invite_url)
}
onSaved?.()
if (!boardId) {
// При создании возвращаемся назад
onNavigate('wishlist', { boardId: data.id })
} else {
// При редактировании возвращаемся на доску
onNavigate('wishlist', { boardId: boardId })
}
} else {
const err = await res.json()
setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' })
}
} catch (err) {
setToastMessage({ text: 'Ошибка сохранения', type: 'error' })
} finally {
setLoading(false)
}
}
// Функция для автоматической генерации ссылки при включении доступа
const generateInviteLink = async () => {
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/regenerate-invite`, {
method: 'POST'
})
if (res.ok) {
const data = await res.json()
setInviteURL(data.invite_url)
setInviteEnabled(true)
}
} catch (err) {
console.error('Error generating invite link:', err)
}
}
const handleCopyLink = () => {
navigator.clipboard.writeText(inviteURL)
setCopied(true)
setToastMessage({ text: 'Ссылка скопирована', type: 'success' })
setTimeout(() => setCopied(false), 2000)
}
const handleToggleInvite = async (enabled) => {
setInviteEnabled(enabled)
if (boardId && enabled && !inviteURL) {
// Автоматически генерируем ссылку при включении
await generateInviteLink()
} else if (boardId) {
// Просто обновляем статус
try {
await authFetch(`/api/wishlist/boards/${boardId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invite_enabled: enabled })
})
} catch (err) {
console.error('Error updating invite status:', err)
}
}
}
const handleDelete = async () => {
if (!window.confirm('Удалить доску? Все желания на ней будут удалены.')) return
setIsDeleting(true)
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}`, {
method: 'DELETE'
})
if (res.ok) {
onSaved?.()
// Передаём флаг, что доска удалена, чтобы Wishlist выбрал первую доступную
onNavigate('wishlist', { boardDeleted: true })
} else {
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
setIsDeleting(false)
}
} catch (err) {
setToastMessage({ text: 'Ошибка удаления', type: 'error' })
setIsDeleting(false)
}
}
const handleClose = () => {
onNavigate('wishlist')
}
if (loadingBoard) {
return (
<div className="board-form">
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
</div>
)
}
return (
<div className="board-form">
<button className="close-x-button" onClick={handleClose}>
</button>
<h2>{isEdit ? 'Настройки доски' : 'Новая доска'}</h2>
<div className="form-card">
<div className="form-group">
<label htmlFor="board-name">Название</label>
<input
id="board-name"
type="text"
className="form-input"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Название доски"
/>
</div>
{isEdit && (
<>
{/* Настройки доступа */}
<div className="form-section">
<h3>Доступ по ссылке</h3>
<label className="toggle-field">
<input
type="checkbox"
checked={inviteEnabled}
onChange={e => handleToggleInvite(e.target.checked)}
/>
<span className="toggle-slider"></span>
<span className="toggle-label">Разрешить присоединение по ссылке</span>
</label>
{inviteEnabled && inviteURL && (
<div className="invite-link-section">
<div className="invite-url-row">
<input
type="text"
className="invite-url-input"
value={inviteURL}
readOnly
/>
<button
className="copy-btn"
onClick={handleCopyLink}
title="Копировать ссылку"
>
{copied ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6L9 17l-5-5"></path>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
)}
</button>
</div>
<p className="invite-hint">
Пользователь, открывший ссылку, сможет присоединиться к доске
</p>
</div>
)}
</div>
{/* Список участников */}
<BoardMembers
boardId={boardId}
onMemberRemoved={() => {
setToastMessage({ text: 'Участник удалён', type: 'success' })
}}
/>
</>
)}
<div className="form-actions">
<SubmitButton
onClick={handleSave}
loading={loading}
disabled={!name.trim()}
>
Сохранить
</SubmitButton>
{isEdit && (
<DeleteButton
onClick={handleDelete}
loading={isDeleting}
disabled={loading}
title="Удалить доску"
/>
)}
</div>
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default BoardForm

View File

@@ -0,0 +1,199 @@
.board-join-preview {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.preview-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
color: white;
}
.preview-loading p {
margin: 0;
font-size: 1.1rem;
}
.preview-card {
background: white;
border-radius: 24px;
padding: 2rem;
max-width: 400px;
width: 100%;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.preview-card.error-card {
max-width: 360px;
}
.invite-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.error-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.preview-card h2 {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
margin: 0 0 1.5rem 0;
}
.board-info {
background: #f9fafb;
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.board-name {
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.75rem;
}
.board-owner,
.board-members {
display: flex;
justify-content: center;
gap: 0.5rem;
font-size: 0.95rem;
color: #6b7280;
margin-top: 0.5rem;
}
.board-owner .value,
.board-members .value {
color: #374151;
font-weight: 500;
}
.error-text {
color: #ef4444;
margin: 0 0 1.5rem 0;
}
.join-error {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 0.75rem;
color: #ef4444;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.join-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 1rem;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white;
border: none;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.join-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(99, 102, 241, 0.4);
}
.join-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
.spinner-small {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.login-prompt {
text-align: center;
}
.login-prompt p {
color: #6b7280;
margin: 0 0 1rem 0;
font-size: 0.95rem;
}
.login-btn {
width: 100%;
padding: 1rem;
background: #6366f1;
color: white;
border: none;
border-radius: 12px;
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.login-btn:hover {
background: #4f46e5;
}
.back-btn {
width: 100%;
padding: 1rem;
background: #f3f4f6;
color: #374151;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.back-btn:hover {
background: #e5e7eb;
}
.cancel-link {
margin-top: 1rem;
background: none;
border: none;
color: #9ca3af;
font-size: 0.95rem;
cursor: pointer;
transition: color 0.2s;
}
.cancel-link:hover {
color: #6b7280;
}

View File

@@ -0,0 +1,156 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './BoardJoinPreview.css'
function BoardJoinPreview({ inviteToken, onNavigate }) {
const { authFetch, user } = useAuth()
const [board, setBoard] = useState(null)
const [loading, setLoading] = useState(true)
const [joining, setJoining] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (inviteToken) {
fetchBoardInfo()
}
}, [inviteToken])
const fetchBoardInfo = async () => {
try {
const res = await authFetch(`/api/wishlist/invite/${inviteToken}`)
if (res.ok) {
setBoard(await res.json())
} else {
const err = await res.json()
setError(err.error || 'Ссылка недействительна или устарела')
}
} catch (err) {
setError('Ошибка загрузки')
} finally {
setLoading(false)
}
}
const handleJoin = async () => {
if (!user) {
// Сохраняем токен для возврата после логина
sessionStorage.setItem('pendingInviteToken', inviteToken)
onNavigate('login')
return
}
setJoining(true)
setError('')
try {
const res = await authFetch(`/api/wishlist/invite/${inviteToken}/join`, {
method: 'POST'
})
if (res.ok) {
const data = await res.json()
// Переходим на доску
onNavigate('wishlist', { boardId: data.board.id })
} else {
const err = await res.json()
setError(err.error || 'Ошибка при присоединении')
}
} catch (err) {
setError('Ошибка при присоединении')
} finally {
setJoining(false)
}
}
const handleGoBack = () => {
onNavigate('wishlist')
}
if (loading) {
return (
<div className="board-join-preview">
<div className="preview-loading">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
<p>Загрузка...</p>
</div>
</div>
)
}
if (error && !board) {
return (
<div className="board-join-preview">
<div className="preview-card error-card">
<div className="error-icon"></div>
<h2>Ошибка</h2>
<p className="error-text">{error}</p>
<button className="back-btn" onClick={handleGoBack}>
Вернуться к желаниям
</button>
</div>
</div>
)
}
return (
<div className="board-join-preview">
<div className="preview-card">
<div className="invite-icon"></div>
<h2>Приглашение на доску</h2>
<div className="board-info">
<div className="board-name">{board.name}</div>
<div className="board-owner">
<span className="label">Владелец:</span>
<span className="value">{board.owner_name}</span>
</div>
{board.member_count > 0 && (
<div className="board-members">
<span className="label">Участников:</span>
<span className="value">{board.member_count}</span>
</div>
)}
</div>
{error && (
<div className="join-error">{error}</div>
)}
{user ? (
<button
className="join-btn"
onClick={handleJoin}
disabled={joining}
>
{joining ? (
<>
<span className="spinner-small"></span>
<span>Присоединение...</span>
</>
) : (
<>
<span>🎉</span>
<span>Присоединиться</span>
</>
)}
</button>
) : (
<div className="login-prompt">
<p>Для присоединения необходимо войти в аккаунт</p>
<button className="login-btn" onClick={() => onNavigate('login')}>
Войти
</button>
</div>
)}
<button className="cancel-link" onClick={handleGoBack}>
Отмена
</button>
</div>
</div>
)
}
export default BoardJoinPreview

View File

@@ -0,0 +1,132 @@
.board-members-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.board-members-section h3 {
font-size: 1rem;
font-weight: 600;
color: #374151;
margin: 0 0 1rem 0;
}
.members-loading {
padding: 1rem;
text-align: center;
color: #9ca3af;
}
.no-members {
padding: 1.5rem;
text-align: center;
background: #f9fafb;
border-radius: 8px;
}
.no-members p {
margin: 0;
color: #6b7280;
}
.no-members .hint {
margin-top: 0.5rem;
font-size: 0.85rem;
color: #9ca3af;
}
.members-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.member-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #f9fafb;
border-radius: 8px;
transition: background 0.15s;
}
.member-item:hover {
background: #f3f4f6;
}
.member-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 1rem;
flex-shrink: 0;
}
.member-info {
flex: 1;
min-width: 0;
}
.member-name {
font-weight: 500;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-date {
font-size: 0.8rem;
color: #9ca3af;
margin-top: 2px;
}
.remove-member-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: 1px solid #e5e7eb;
border-radius: 6px;
color: #9ca3af;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.remove-member-btn:hover:not(:disabled) {
background: #fef2f2;
border-color: #fecaca;
color: #ef4444;
}
.remove-member-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner-small {
width: 14px;
height: 14px;
border: 2px solid #d1d5db;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,113 @@
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import './BoardMembers.css'
function BoardMembers({ boardId, onMemberRemoved }) {
const { authFetch } = useAuth()
const [members, setMembers] = useState([])
const [loading, setLoading] = useState(true)
const [removingId, setRemovingId] = useState(null)
useEffect(() => {
if (boardId) {
fetchMembers()
}
}, [boardId])
const fetchMembers = async () => {
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/members`)
if (res.ok) {
const data = await res.json()
setMembers(data || [])
}
} catch (err) {
console.error('Error fetching members:', err)
} finally {
setLoading(false)
}
}
const handleRemoveMember = async (userId) => {
if (!window.confirm('Удалить участника из доски?')) return
setRemovingId(userId)
try {
const res = await authFetch(`/api/wishlist/boards/${boardId}/members/${userId}`, {
method: 'DELETE'
})
if (res.ok) {
setMembers(members.filter(m => m.user_id !== userId))
onMemberRemoved?.()
}
} catch (err) {
console.error('Error removing member:', err)
} finally {
setRemovingId(null)
}
}
const formatDate = (dateStr) => {
const date = new Date(dateStr)
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
year: 'numeric'
})
}
if (loading) {
return (
<div className="board-members-section">
<h3>Участники</h3>
<div className="members-loading">Загрузка...</div>
</div>
)
}
return (
<div className="board-members-section">
<h3>Участники ({members.length})</h3>
{members.length === 0 ? (
<div className="no-members">
<p>Пока никто не присоединился к доске</p>
<p className="hint">Поделитесь ссылкой, чтобы пригласить участников</p>
</div>
) : (
<div className="members-list">
{members.map(member => (
<div key={member.id} className="member-item">
<div className="member-avatar">
{(member.name || member.email).charAt(0).toUpperCase()}
</div>
<div className="member-info">
<div className="member-name">
{member.name || member.email}
</div>
<div className="member-date">
Присоединился {formatDate(member.joined_at)}
</div>
</div>
<button
className="remove-member-btn"
onClick={() => handleRemoveMember(member.user_id)}
disabled={removingId === member.user_id}
title="Удалить участника"
>
{removingId === member.user_id ? (
<span className="spinner-small"></span>
) : (
'✕'
)}
</button>
</div>
))}
</div>
)}
</div>
)
}
export default BoardMembers

View File

@@ -0,0 +1,242 @@
.board-selector {
position: relative;
max-width: 42rem;
margin: 0 auto;
padding-top: 0;
padding-bottom: 16px;
}
/* Дополнительный отступ сверху на больших экранах, чтобы соответствовать кнопке "Добавить" на экране задач */
@media (min-width: 768px) {
.board-selector {
margin-top: 0.5rem; /* 8px - разница между md:p-8 (32px) и md:p-6 (24px) */
}
}
.board-header {
display: flex;
gap: 12px;
align-items: center;
}
/* Основная кнопка-pill */
.board-pill {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
height: 52px;
padding: 0 20px;
background: white;
border: none;
border-radius: 26px;
font-size: 17px;
font-weight: 500;
color: #1f2937;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
}
.board-pill:hover:not(:disabled) {
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15), 0 2px 4px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
.board-pill:active:not(:disabled) {
transform: translateY(0);
}
.board-pill.open {
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2), 0 2px 4px rgba(0, 0, 0, 0.08);
}
.board-pill:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.shared-icon {
font-size: 16px;
flex-shrink: 0;
}
.board-label {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
color: #9ca3af;
flex-shrink: 0;
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.chevron.rotated {
transform: rotate(180deg);
}
/* Кнопка действия (настройки/выход) */
.board-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
padding: 0;
background: white;
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
color: #6b7280;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
}
.board-action-btn:hover {
background: #f9fafb;
color: #374151;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.board-action-btn:active {
transform: translateY(0);
}
.board-action-btn svg {
width: 22px;
height: 22px;
}
/* Выпадающий список */
.board-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: white;
border-radius: 18px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12), 0 2px 10px rgba(0, 0, 0, 0.08);
z-index: 100;
overflow: hidden;
opacity: 0;
visibility: hidden;
transform: translateY(-8px) scale(0.98);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.board-dropdown.visible {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.dropdown-content {
padding: 10px;
}
.dropdown-list {
max-height: 280px;
overflow-y: auto;
}
.dropdown-empty {
padding: 28px 16px;
text-align: center;
color: #9ca3af;
font-size: 15px;
}
/* Элементы списка */
.dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 14px 16px;
border: none;
background: transparent;
border-radius: 12px;
font-size: 16px;
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
color: #374151;
}
.dropdown-item:hover {
background: #f3f4f6;
}
.dropdown-item.selected {
background: linear-gradient(135deg, #667eea10 0%, #764ba210 100%);
color: #4f46e5;
}
.dropdown-item.selected:hover {
background: linear-gradient(135deg, #667eea18 0%, #764ba218 100%);
}
.item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.item-meta {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.item-badge {
font-size: 14px;
}
.item-members {
display: flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 26px;
padding: 0 8px;
background: #e5e7eb;
border-radius: 13px;
font-size: 13px;
font-weight: 600;
color: #6b7280;
}
.check-icon {
color: #4f46e5;
}
/* Кнопка добавления доски */
.dropdown-item.add-board {
margin-top: 6px;
padding-top: 14px;
border-top: 1px solid #f3f4f6;
border-radius: 0 0 12px 12px;
color: #667eea;
font-weight: 500;
gap: 12px;
justify-content: flex-start;
}
.dropdown-item.add-board:hover {
background: linear-gradient(135deg, #667eea08 0%, #764ba208 100%);
}
.dropdown-item.add-board svg {
flex-shrink: 0;
width: 20px;
height: 20px;
}

View File

@@ -0,0 +1,132 @@
import React, { useState, useEffect, useRef } from 'react'
import './BoardSelector.css'
function BoardSelector({
boards,
selectedBoardId,
onBoardChange,
onBoardEdit,
onAddBoard,
loading
}) {
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef(null)
const selectedBoard = boards.find(b => b.id === selectedBoardId)
// Закрытие при клике снаружи
useEffect(() => {
const handleClickOutside = (e) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSelectBoard = (board) => {
onBoardChange(board.id)
setIsOpen(false)
}
return (
<div className="board-selector" ref={dropdownRef}>
<div className="board-header">
<button
className={`board-pill ${isOpen ? 'open' : ''}`}
onClick={() => setIsOpen(!isOpen)}
disabled={loading}
>
{!selectedBoard?.is_owner && selectedBoard && (
<span className="shared-icon">👥</span>
)}
<span className="board-label">
{loading ? 'Загрузка...' : (selectedBoard?.name || 'Выберите доску')}
</span>
<svg
className={`chevron ${isOpen ? 'rotated' : ''}`}
width="14"
height="14"
viewBox="0 0 12 12"
>
<path
d="M2.5 4.5L6 8L9.5 4.5"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{selectedBoard && (
<button
className="board-action-btn"
onClick={onBoardEdit}
title={selectedBoard.is_owner ? 'Настройки доски' : 'Покинуть доску'}
>
{selectedBoard.is_owner ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1.5"></circle>
<circle cx="19" cy="12" r="1.5"></circle>
<circle cx="5" cy="12" r="1.5"></circle>
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
)}
</button>
)}
</div>
<div className={`board-dropdown ${isOpen ? 'visible' : ''}`}>
<div className="dropdown-content">
{boards.length === 0 ? (
<div className="dropdown-empty">
Нет досок
</div>
) : (
<div className="dropdown-list">
{boards.map(board => (
<button
key={board.id}
className={`dropdown-item ${board.id === selectedBoardId ? 'selected' : ''}`}
onClick={() => handleSelectBoard(board)}
>
<span className="item-name">{board.name}</span>
<div className="item-meta">
{!board.is_owner && <span className="item-badge shared">👥</span>}
{board.member_count > 0 && (
<span className="item-members">{board.member_count}</span>
)}
{board.id === selectedBoardId && (
<svg className="check-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
)}
</div>
</button>
))}
</div>
)}
<button className="dropdown-item add-board" onClick={onAddBoard}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="16"></line>
<line x1="8" y1="12" x2="16" y2="12"></line>
</svg>
<span>Создать доску</span>
</button>
</div>
</div>
</div>
)
}
export default BoardSelector

View File

@@ -0,0 +1,56 @@
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.submit-button {
background: linear-gradient(to right, #6366f1, #8b5cf6);
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
flex: 1;
}
.submit-button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.submit-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.delete-button {
background: #ef4444;
color: white;
padding: 0.75rem;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
width: 44px;
}
.delete-button:hover:not(:disabled) {
background: #dc2626;
}
.delete-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -1,197 +1,279 @@
import React from 'react'
import ProjectProgressBar from './ProjectProgressBar' import ProjectProgressBar from './ProjectProgressBar'
import LoadingError from './LoadingError'
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils' import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
import { CircularProgressbar, buildStyles } from 'react-circular-progressbar'
import 'react-circular-progressbar/dist/styles.css'
function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate }) { // Компонент круглого прогрессбара с использованием react-circular-progressbar
// Обрабатываем данные: может быть объект с projects и total, или просто массив function CircularProgressBar({ progress, size = 120, strokeWidth = 8, showCheckmark = true, extraProgress = null, maxProgress = 100, textSize = 'large', displayProgress = null, textPosition = 'default', projectColor = null }) {
const projectsData = data?.projects || (Array.isArray(data) ? data : []) // Нормализуем прогресс для визуализации (0-100%)
const normalizedProgress = Math.min(Math.max(progress || 0, 0), 100)
// Показываем loading только если данных нет и идет загрузка // Если есть extra progress, вычисляем визуальный прогресс для overlay
if (loading && (!data || projectsData.length === 0)) { const extraVisual = extraProgress !== null && extraProgress > 0
return ( ? Math.min((extraProgress / maxProgress) * 100, 100)
<div className="flex justify-center items-center py-16"> : 0
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка данных...</div>
</div>
</div>
)
}
if (error && (!data || projectsData.length === 0)) { // Определяем, достигнут ли 100% или выше
return ( const isComplete = (displayProgress !== null ? displayProgress : progress) >= 100
<div className="flex flex-col items-center justify-center py-16">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-4 max-w-md"> // Определяем градиент ID: зелёный если >= 100%, иначе по наличию extra progress
<div className="text-red-700 font-semibold mb-2">Ошибка загрузки</div> const gradientId = isComplete ? 'success-gradient' : (extraVisual > 0 ? 'project-gradient' : 'overall-gradient')
<div className="text-red-600 text-sm">{error}</div> const extraGradientId = 'project-extra-gradient'
</div>
<button // Определяем класс размера текста
onClick={onRetry} const textSizeClass = textSize === 'large' ? 'text-4xl' : textSize === 'small' ? 'text-base' : 'text-lg'
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl font-semibold"
// Используем displayProgress если передан (может быть больше 100%), иначе progress
const progressToDisplay = displayProgress !== null ? displayProgress : progress
return (
<div className="relative" style={{ width: size, height: size }}>
<CircularProgressbar
value={normalizedProgress}
strokeWidth={strokeWidth / size * 100}
styles={buildStyles({
// Цвета
pathColor: `url(#${gradientId})`,
trailColor: '#e5e7eb',
// Анимация
pathTransitionDuration: 1,
// Размер текста (убираем встроенный)
textSize: '0px',
// Поворот, чтобы пустая часть была снизу
rotation: 0.625,
strokeLinecap: 'round',
})}
// Создаем неполный круг (270 градусов)
circleRatio={0.75}
/>
{/* Extra progress overlay (если есть) */}
{extraVisual > 0 && (
<CircularProgressbar
value={extraVisual}
strokeWidth={strokeWidth / size * 100}
styles={buildStyles({
rotation: 0.625,
strokeLinecap: 'round',
textSize: '0px',
pathColor: `url(#${extraGradientId})`,
trailColor: 'transparent',
pathTransitionDuration: 1,
})}
circleRatio={0.75}
className="absolute inset-0"
/>
)}
{/* Иконка статистики в центре */}
<div className="absolute inset-0 flex items-center justify-center">
<svg
width={size * 0.3}
height={size * 0.3}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{
color: isComplete ? '#10b981' : '#4f46e5'
}}
> >
Попробовать снова <line x1="18" y1="20" x2="18" y2="10"></line>
</button> <line x1="12" y1="20" x2="12" y2="4"></line>
<line x1="6" y1="20" x2="6" y2="14"></line>
</svg>
</div> </div>
)
{/* Кастомный текст снизу */}
<div className={`absolute inset-0 flex justify-center items-end ${textPosition === 'lower' ? '' : 'pb-2'}`} style={textPosition === 'lower' ? { bottom: '0.125rem' } : {}}>
<div className="text-center">
<div className={`${textSizeClass} font-bold`} style={{ color: isComplete ? '#10b981' : '#4f46e5' }}>
{progressToDisplay !== null && progressToDisplay !== undefined ? `${progressToDisplay.toFixed(0)}%` : 'N/A'}
</div>
</div>
</div>
{/* Градиенты для SVG */}
<svg width="0" height="0">
<defs>
<linearGradient id="overall-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#4f46e5" />
<stop offset="100%" stopColor="#9333ea" />
</linearGradient>
<linearGradient id="project-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#4f46e5" />
<stop offset="100%" stopColor="#9333ea" />
</linearGradient>
<linearGradient id="project-extra-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#f59e0b" />
<stop offset="100%" stopColor="#d97706" />
</linearGradient>
<linearGradient id="success-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#10b981" />
<stop offset="100%" stopColor="#059669" />
</linearGradient>
</defs>
</svg>
</div>
)
}
// Компонент карточки проекта с круглым прогрессбаром
function ProjectCard({ project, projectColor, onProjectClick }) {
const { project_name, total_score, min_goal_score, max_goal_score, priority } = project
// Вычисляем прогресс по оригинальной логике из ProjectProgressBar
const getGoalProgress = () => {
const safeTotal = Number.isFinite(total_score) ? total_score : 0
const safeMinGoal = Number.isFinite(min_goal_score) ? min_goal_score : 0
const safeMaxGoal = Number.isFinite(max_goal_score) ? max_goal_score : 0
const normalizedPriority = (() => {
if (priority === null || priority === undefined) return null
const numeric = Number(priority)
return Number.isFinite(numeric) ? numeric : null
})()
const priorityBonus = (() => {
if (normalizedPriority === 1) return 50
if (normalizedPriority === 2) return 35
return 20
})()
// Если нет валидного minGoal, возвращаем прогресс относительно maxGoal либо 0
if (safeMinGoal <= 0) {
if (safeMaxGoal > 0) {
return Math.max(0, Math.min((safeTotal / safeMaxGoal) * 100, 100))
}
return 0
}
// До достижения minGoal растем линейно от 0 до 100%
const baseProgress = Math.max(0, Math.min((safeTotal / safeMinGoal) * 100, 100))
// Если maxGoal не задан корректно или еще не достигнут minGoal, показываем базовый прогресс
if (safeTotal < safeMinGoal || safeMaxGoal <= safeMinGoal) {
return baseProgress
}
// Между minGoal и maxGoal добавляем бонус в зависимости от приоритета
const extraRange = safeMaxGoal - safeMinGoal
const extraRatio = Math.min(1, Math.max(0, (safeTotal - safeMinGoal) / extraRange))
const extraProgress = extraRatio * priorityBonus
// Выше maxGoal прогресс не растет
return Math.min(100 + priorityBonus, 100 + extraProgress)
} }
// Процент выполнения берем только из данных API const goalProgress = getGoalProgress()
const overallProgress = (() => { const maxProgressForPriority = 100 + (() => {
// Проверяем различные возможные названия поля const normalizedPriority = (() => {
const rawValue = data?.total ?? data?.progress ?? data?.percentage ?? data?.completion ?? data?.goal_progress if (priority === null || priority === undefined) return null
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue) const numeric = Number(priority)
return Number.isFinite(numeric) ? numeric : null
if (Number.isFinite(parsedValue) && parsedValue >= 0) { })()
return Math.max(0, parsedValue) // Убрали ограничение на 100, так как может быть больше if (normalizedPriority === 1) return 50
} if (normalizedPriority === 2) return 35
return 20
return null // null означает, что данные не пришли
})() })()
// Для визуального отображения: 100% прогрессбара = максимум для данного приоритета
// visualProgress показывает процент заполнения прогрессбара (0-100%), где 100% = maxProgressForPriority
const visualProgress = Math.min((goalProgress / maxProgressForPriority) * 100, 100)
const hasProgressData = overallProgress !== null // Для extra overlay: если goalProgress > 100%, показываем extra часть
// Но визуально это уже учтено в visualProgress, так что extra overlay не нужен
// Логирование для отладки // Однако если нужно показать, что достигнут максимум, можно использовать другой подход
console.log('CurrentWeek data:', { const baseVisualProgress = visualProgress
data, const extraVisualProgress = 0 // Не используем extra overlay, так как visualProgress уже показывает весь прогресс
dataTotal: data?.total,
dataProgress: data?.progress,
dataPercentage: data?.percentage,
overallProgress,
hasProgressData
})
if (!projectsData || projectsData.length === 0) { // Вычисляем целевую зону
return ( const getTargetZone = () => {
<div className="flex justify-center items-center py-16"> const safeMinGoal = Number.isFinite(min_goal_score) ? min_goal_score : 0
<div className="text-gray-500 text-lg">Нет данных для отображения</div> const safeMaxGoal = Number.isFinite(max_goal_score) ? max_goal_score : 0
</div>
)
}
// Получаем отсортированный список всех проектов для синхронизации цветов if (safeMinGoal > 0 && safeMaxGoal > 0) {
const allProjects = getAllProjectsSorted(allProjectsData, projectsData) return `${safeMinGoal.toFixed(0)} - ${safeMaxGoal.toFixed(0)}`
} else if (safeMinGoal > 0) {
const normalizePriority = (value) => { return `${safeMinGoal.toFixed(0)}+`
if (value === null || value === undefined) return Infinity
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : Infinity
}
// Сортируем: сначала по priority (1, 2, ...; null в конце), затем по min_goal_score по убыванию
const sortedData = [...projectsData].sort((a, b) => {
const priorityA = normalizePriority(a.priority)
const priorityB = normalizePriority(b.priority)
if (priorityA !== priorityB) {
return priorityA - priorityB
} }
return '0+'
}
const minGoalA = parseFloat(a.min_goal_score) || 0 const handleClick = () => {
const minGoalB = parseFloat(b.min_goal_score) || 0 if (onProjectClick) {
return minGoalB - minGoalA onProjectClick(project_name)
}) }
}
return (
<div
onClick={handleClick}
className="bg-white rounded-3xl py-3 px-4 shadow-sm hover:shadow-md transition-all duration-300 cursor-pointer border border-gray-200 hover:border-indigo-300"
>
{/* Верхняя часть с названием и прогрессом */}
<div className="flex items-center justify-between">
{/* Левая часть - текст (название, баллы, целевая зона) */}
<div className="flex-1 min-w-0">
<div className="text-base font-semibold text-gray-600 leading-normal truncate mb-0.5">
{project_name}
</div>
<div className="text-3xl font-bold text-black leading-normal mb-0.5">
{total_score?.toFixed(1) || '0.0'}
</div>
<div className="text-xs text-gray-500 leading-normal">
Целевая зона: {getTargetZone()}
</div>
</div>
{/* Правая часть - круглый прогрессбар */}
<div className="flex-shrink-0 ml-3">
<CircularProgressBar
progress={baseVisualProgress}
size={80}
strokeWidth={8}
textSize="small"
displayProgress={goalProgress}
textPosition="lower"
projectColor={projectColor}
/>
</div>
</div>
</div>
)
}
// Компонент группы проектов по приоритету
function PriorityGroup({ title, subtitle, projects, allProjects, onProjectClick }) {
if (projects.length === 0) return null
return ( return (
<div> <div>
{/* Информация об общем проценте выполнения целей */} {/* Заголовок группы */}
<div className="mb-3 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg p-4 border border-indigo-200"> <div className="flex items-center gap-2 mb-3">
<div className="flex items-stretch justify-between gap-4"> <h2 className="text-xl text-black">{title}</h2>
<div className="min-w-0 flex-1 flex items-center gap-4"> <span className="text-black text-xl font-bold"></span>
<div className="flex-1"> <span className="text-lg font-bold text-black">{subtitle}</span>
<div className="text-sm sm:text-base text-gray-600 mb-1">Выполнение целей</div>
<div className="text-2xl sm:text-3xl lg:text-4xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
{hasProgressData ? `${overallProgress.toFixed(1)}%` : 'N/A'}
</div>
</div>
{hasProgressData && (
<div className="w-12 h-12 sm:w-16 sm:h-16 relative flex-shrink-0">
<svg className="transform -rotate-90" viewBox="0 0 64 64">
<circle
cx="32"
cy="32"
r="28"
stroke="currentColor"
strokeWidth="6"
fill="none"
className="text-gray-200"
/>
<circle
cx="32"
cy="32"
r="28"
stroke="url(#gradient)"
strokeWidth="6"
fill="none"
strokeDasharray={`${Math.min(overallProgress / 100, 1) * 175.93} 175.93`}
strokeLinecap="round"
/>
{overallProgress >= 100 && (
<g className="transform rotate-90" style={{ transformOrigin: '32px 32px' }}>
<path
d="M 20 32 L 28 40 L 44 24"
stroke="url(#gradient)"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</g>
)}
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#4f46e5" />
<stop offset="100%" stopColor="#9333ea" />
</linearGradient>
</defs>
</svg>
</div>
)}
</div>
{onNavigate && (
<div className="flex flex-col flex-shrink-0 gap-1" style={{ minHeight: '64px', height: '100%' }}>
<button
onClick={() => onNavigate('full')}
className="flex-1 flex items-center justify-center px-4 bg-white hover:bg-indigo-50 text-indigo-600 hover:text-indigo-700 rounded-lg border border-indigo-200 hover:border-indigo-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Статистика"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10"></line>
<line x1="12" y1="20" x2="12" y2="4"></line>
<line x1="6" y1="20" x2="6" y2="14"></line>
</svg>
</button>
<button
onClick={() => onNavigate('priorities')}
className="flex-1 flex items-center justify-center px-4 bg-white hover:bg-indigo-50 text-indigo-600 hover:text-indigo-700 rounded-lg border border-indigo-200 hover:border-indigo-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Проекты"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</button>
</div>
)}
</div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"> {/* Карточки проектов */}
{sortedData.map((project, index) => { <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{projects.map((project, index) => {
if (!project || !project.project_name) return null
const projectColor = getProjectColor(project.project_name, allProjects) const projectColor = getProjectColor(project.project_name, allProjects)
return ( return (
<div key={index}> <ProjectCard
<ProjectProgressBar key={index}
projectName={project.project_name} project={project}
totalScore={parseFloat(project.total_score)} projectColor={projectColor}
minGoalScore={parseFloat(project.min_goal_score)} onProjectClick={onProjectClick}
maxGoalScore={parseFloat(project.max_goal_score)} />
onProjectClick={onProjectClick}
projectColor={projectColor}
priority={project.priority}
/>
</div>
) )
})} })}
</div> </div>
@@ -199,5 +281,168 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
) )
} }
function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate }) {
// Обрабатываем данные: может быть объект с projects и total, или просто массив
const projectsData = data?.projects || (Array.isArray(data) ? data : []) || []
// Показываем loading только если данных нет и идет загрузка
if (loading && (!data || projectsData.length === 0)) {
return (
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
)
}
if (error && (!data || projectsData.length === 0)) {
return <LoadingError onRetry={onRetry} />
}
// Процент выполнения берем только из данных API
const overallProgress = (() => {
// Проверяем различные возможные названия поля
const rawValue = data?.total ?? data?.progress ?? data?.percentage ?? data?.completion ?? data?.goal_progress
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue)
if (Number.isFinite(parsedValue) && parsedValue >= 0) {
return Math.max(0, parsedValue) // Убрали ограничение на 100, так как может быть больше
}
return null // null означает, что данные не пришли
})()
const hasProgressData = overallProgress !== null
// Получаем отсортированный список всех проектов для синхронизации цветов
const allProjects = getAllProjectsSorted(allProjectsData, projectsData || [])
const normalizePriority = (value) => {
if (value === null || value === undefined) return Infinity
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : Infinity
}
// Группируем проекты по приоритетам
const priorityGroups = {
main: [], // priority === 1
important: [], // priority === 2
others: [] // остальные
}
if (projectsData && projectsData.length > 0) {
projectsData.forEach(project => {
if (!project || !project.project_name) return
const priority = normalizePriority(project.priority)
if (priority === 1) {
priorityGroups.main.push(project)
} else if (priority === 2) {
priorityGroups.important.push(project)
} else {
priorityGroups.others.push(project)
}
})
// Сортируем внутри каждой группы по min_goal_score по убыванию
Object.values(priorityGroups).forEach(group => {
group.sort((a, b) => {
const minGoalA = parseFloat(a.min_goal_score) || 0
const minGoalB = parseFloat(b.min_goal_score) || 0
return minGoalB - minGoalA
})
})
}
// Получаем проценты групп из API данных
const mainProgress = (() => {
const rawValue = data?.group_progress_1
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue)
return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : 0
})()
const importantProgress = (() => {
const rawValue = data?.group_progress_2
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue)
return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : 0
})()
const othersProgress = (() => {
const rawValue = data?.group_progress_0
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue)
return Number.isFinite(parsedValue) && parsedValue >= 0 ? parsedValue : 0
})()
// Используем общий прогресс из API данных
const displayOverallProgress = overallProgress
return (
<div className="relative pt-8">
{/* Кнопка "Приоритеты" в правом верхнем углу */}
{onNavigate && (
<div className="absolute top-0 right-0 z-10">
<button
onClick={() => onNavigate('priorities')}
className="flex items-center justify-center w-10 h-10 text-gray-600 hover:text-indigo-600 transition-colors duration-200"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="m18.5 2.5 3 3L12 15l-4 1 1-4 9.5-9.5Z"></path>
</svg>
</button>
</div>
)}
{/* Общий прогресс - большой круг в центре */}
<div className="flex flex-col items-center mb-6">
<div className="relative mb-6 cursor-pointer" onClick={() => onNavigate && onNavigate('full')}>
<CircularProgressBar
progress={displayOverallProgress}
size={180}
strokeWidth={12}
showCheckmark={true}
displayProgress={displayOverallProgress}
/>
{/* Подсказка при наведении */}
<div className="absolute inset-0 rounded-full opacity-0 hover:opacity-100 transition-opacity duration-200 bg-black bg-opacity-10 flex items-center justify-center">
<span className="text-xs text-gray-600 font-medium bg-white px-2 py-1 rounded shadow-sm">
Открыть статистику
</span>
</div>
</div>
</div>
{/* Группы проектов по приоритетам */}
<div className="space-y-6">
<PriorityGroup
title="Главный"
subtitle={`${Math.round(mainProgress)}%`}
projects={priorityGroups.main}
allProjects={allProjects}
onProjectClick={onProjectClick}
/>
<PriorityGroup
title="Важные"
subtitle={`${Math.round(importantProgress)}%`}
projects={priorityGroups.important}
allProjects={allProjects}
onProjectClick={onProjectClick}
/>
<PriorityGroup
title="Остальные"
subtitle={`${Math.round(othersProgress)}%`}
projects={priorityGroups.others}
allProjects={allProjects}
onProjectClick={onProjectClick}
/>
</div>
</div>
)
}
export default CurrentWeek export default CurrentWeek

View File

@@ -0,0 +1,29 @@
import React from 'react'
import './Buttons.css'
function DeleteButton({ loading, disabled, onClick, title = 'Удалить', ...props }) {
return (
<button
type="button"
className="delete-button"
onClick={onClick}
disabled={disabled || loading}
title={title}
{...props}
>
{loading ? (
<span>...</span>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18"></path>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
)}
</button>
)
}
export default DeleteButton

View File

@@ -1,269 +1,40 @@
.config-selection { .dictionary-list {
padding-top: 0; padding-top: 0;
padding-left: 1rem;
padding-right: 1rem;
}
@media (min-width: 768px) {
.config-selection {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
.add-config-button {
background: transparent;
border: 2px dashed #3498db;
border-radius: 12px;
padding: 1.5rem 1rem;
transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
min-height: 180px;
position: relative; position: relative;
padding-bottom: 5rem;
} }
.add-config-button:hover { .dictionary-close-button {
transform: translateY(-2px); position: fixed;
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3); top: 1rem;
background-color: rgba(52, 152, 219, 0.05); right: 1rem;
border-color: #2980b9; background: rgba(255, 255, 255, 0.9);
}
.add-config-icon {
font-size: 3rem;
font-weight: bold;
color: #3498db;
margin-bottom: auto;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
line-height: 1;
}
.add-config-text {
font-size: 1rem;
font-weight: 500;
color: #3498db;
text-align: center;
margin-top: auto;
padding-top: 0.5rem;
}
.loading, .error-message {
text-align: center;
padding: 2rem;
color: #666;
}
.error-message {
color: #e74c3c;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
}
.configs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
.config-card {
background: #3498db;
border-radius: 12px;
padding: 1.5rem 1rem;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
min-height: 180px;
position: relative;
}
.card-menu-button {
position: absolute;
top: 0.5rem;
right: 0;
background: transparent;
border: none; border: none;
border-radius: 6px; font-size: 1.5rem;
color: #7f8c8d;
cursor: pointer;
width: 40px; width: 40px;
height: 40px; height: 40px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; border-radius: 50%;
font-size: 1.75rem; transition: background-color 0.2s, color 0.2s;
color: white; z-index: 1600;
font-weight: bold; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: all 0.2s;
z-index: 10;
padding: 0;
line-height: 1;
} }
.card-menu-button:hover { .dictionary-close-button:hover {
opacity: 0.8; background-color: #ffffff;
transform: scale(1.1);
}
.config-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
}
.config-words-count {
font-size: 2.5rem;
font-weight: bold;
color: white;
margin-bottom: auto;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
.config-max-cards {
font-size: 1.5rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
margin-top: -1rem;
margin-bottom: auto;
}
.config-name {
font-size: 1rem;
font-weight: 500;
color: white;
text-align: center;
margin-top: auto;
padding-top: 0.5rem;
}
.config-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.config-modal {
background: white;
border-radius: 12px;
padding: 0;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.config-modal-header {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
position: relative;
}
.config-modal-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.75rem;
text-align: center;
}
.config-modal-close:hover {
background-color: #f0f0f0;
}
.config-modal-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
}
.config-modal-edit,
.config-modal-delete {
width: 100%;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.config-modal-edit {
background-color: #3498db;
color: white;
}
.config-modal-edit:hover {
background-color: #2980b9;
transform: translateY(-1px);
}
.config-modal-delete {
background-color: #e74c3c;
color: white;
}
.config-modal-delete:hover {
background-color: #c0392b;
transform: translateY(-1px);
}
.section-divider {
margin: 0.5rem 0 1rem 0;
padding-bottom: 0.75rem;
border-bottom: 2px solid #e0e0e0;
}
.section-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #2c3e50; color: #2c3e50;
} }
.dictionaries-section { .dictionaries-grid {
margin-top: 2rem; display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
padding-top: 4rem;
margin-bottom: 1rem;
} }
.dictionary-card { .dictionary-card {
@@ -281,13 +52,31 @@
position: relative; position: relative;
} }
.dictionary-card .card-menu-button { .dictionary-list .dictionary-card .dictionary-menu-button {
background: transparent; position: absolute;
color: #2c3e50; top: 0.5rem;
right: 0;
background: transparent !important;
border: none !important;
border-radius: 6px !important;
width: 40px !important;
height: 40px !important;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1.5rem !important;
color: #2c3e50 !important;
font-weight: bold;
transition: all 0.2s;
z-index: 10;
padding: 0;
line-height: 1;
} }
.dictionary-card .card-menu-button:hover { .dictionary-list .dictionary-card .dictionary-menu-button:hover {
opacity: 0.7; opacity: 0.7;
transform: scale(1.1);
} }
.dictionary-card:hover { .dictionary-card:hover {
@@ -337,11 +126,99 @@
border-color: #1a252f; border-color: #1a252f;
} }
.add-dictionary-button .add-config-icon { .add-dictionary-icon {
font-size: 3rem;
font-weight: bold;
color: #000000; color: #000000;
margin-bottom: auto;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
line-height: 1;
} }
.add-dictionary-button .add-config-text { .add-dictionary-text {
font-size: 1rem;
font-weight: 500;
color: #000000; color: #000000;
text-align: center;
margin-top: auto;
padding-top: 0.5rem;
}
/* Modal styles */
.dictionary-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dictionary-modal {
background: white;
border-radius: 12px;
padding: 0;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: dictionaryModalSlideIn 0.2s ease-out;
}
@keyframes dictionaryModalSlideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.dictionary-modal-header {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
position: relative;
}
.dictionary-modal-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.75rem;
text-align: center;
}
.dictionary-modal-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
}
.dictionary-modal-delete {
width: 100%;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
background-color: #e74c3c;
color: white;
}
.dictionary-modal-delete:hover {
background-color: #c0392b;
transform: translateY(-1px);
} }

View File

@@ -0,0 +1,155 @@
import React, { useState, useEffect, useRef } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import './DictionaryList.css'
const API_URL = '/api'
function DictionaryList({ onNavigate, refreshTrigger = 0 }) {
const { authFetch } = useAuth()
const [dictionaries, setDictionaries] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [selectedDictionary, setSelectedDictionary] = useState(null)
const isInitializedRef = useRef(false)
const dictionariesRef = useRef([])
// Обновляем ref при изменении состояния
useEffect(() => {
dictionariesRef.current = dictionaries
}, [dictionaries])
useEffect(() => {
fetchDictionaries()
}, [refreshTrigger])
const fetchDictionaries = async () => {
try {
// Показываем загрузку только при первой инициализации или если нет данных для отображения
const isFirstLoad = !isInitializedRef.current
const hasData = !isFirstLoad && dictionariesRef.current.length > 0
if (!hasData) {
setLoading(true)
}
const response = await authFetch(`${API_URL}/test-configs-and-dictionaries`)
if (!response.ok) {
throw new Error('Ошибка при загрузке словарей')
}
const data = await response.json()
setDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
setError('')
isInitializedRef.current = true
} catch (err) {
setError(err.message)
setDictionaries([])
isInitializedRef.current = true
} finally {
setLoading(false)
}
}
const handleDictionarySelect = (dict) => {
onNavigate?.('words', { dictionaryId: dict.id, dictionaryName: dict.name })
}
const handleDictionaryMenuClick = (dict, e) => {
e.stopPropagation()
setSelectedDictionary(dict)
}
const handleDictionaryDelete = async () => {
if (!selectedDictionary) return
try {
const response = await authFetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
console.error('Delete error:', response.status, errorText)
throw new Error(`Ошибка при удалении словаря: ${response.status}`)
}
setSelectedDictionary(null)
// Refresh dictionaries list
await fetchDictionaries()
} catch (err) {
console.error('Delete failed:', err)
setError(err.message)
setSelectedDictionary(null)
}
}
const closeDictionaryModal = () => {
setSelectedDictionary(null)
}
return (
<div className="dictionary-list">
{/* Кнопка закрытия */}
<button
className="dictionary-close-button"
onClick={() => onNavigate?.('profile')}
title="Закрыть"
>
</button>
{/* Показываем загрузку только при первой инициализации и если нет данных для отображения */}
{loading && !isInitializedRef.current && dictionaries.length === 0 ? (
<div className="fixed inset-0 bottom-20 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
) : error ? (
<LoadingError onRetry={fetchDictionaries} />
) : (
<div className="dictionaries-grid">
{dictionaries.map((dict) => (
<div
key={dict.id}
className="dictionary-card"
onClick={() => handleDictionarySelect(dict)}
>
<button
onClick={(e) => handleDictionaryMenuClick(dict, e)}
className="dictionary-menu-button"
title="Меню"
>
</button>
<div className="dictionary-words-count">
{dict.wordsCount}
</div>
<div className="dictionary-name">{dict.name}</div>
</div>
))}
</div>
)}
{selectedDictionary && (
<div className="dictionary-modal-overlay" onClick={closeDictionaryModal}>
<div className="dictionary-modal" onClick={(e) => e.stopPropagation()}>
<div className="dictionary-modal-header">
<h3>{selectedDictionary.name}</h3>
</div>
<div className="dictionary-modal-actions">
<button className="dictionary-modal-delete" onClick={handleDictionaryDelete}>
Удалить
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default DictionaryList

View File

@@ -12,7 +12,9 @@ import {
} from 'chart.js' } from 'chart.js'
import { Line } from 'react-chartjs-2' import { Line } from 'react-chartjs-2'
import WeekProgressChart from './WeekProgressChart' import WeekProgressChart from './WeekProgressChart'
import LoadingError from './LoadingError'
import { getAllProjectsSorted, getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils' import { getAllProjectsSorted, getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
import './Integrations.css'
// Экспортируем для обратной совместимости (если используется в других местах) // Экспортируем для обратной совместимости (если используется в других местах)
export { getProjectColorByIndex } from '../utils/projectUtils' export { getProjectColorByIndex } from '../utils/projectUtils'
@@ -115,33 +117,8 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
const chartData = processData() const chartData = processData()
// Показываем loading только если данных нет и идет загрузка if (error && !chartData && !loading) {
if (loading && !chartData) { return <LoadingError onRetry={onRetry} />
return (
<div className="flex justify-center items-center py-16">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка данных...</div>
</div>
</div>
)
}
if (error && !chartData) {
return (
<div className="flex flex-col items-center justify-center py-16">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-4 max-w-md">
<div className="text-red-700 font-semibold mb-2">Ошибка загрузки</div>
<div className="text-red-600 text-sm">{error}</div>
</div>
<button
onClick={onRetry}
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl font-semibold"
>
Попробовать снова
</button>
</div>
)
} }
const chartOptions = { const chartOptions = {
@@ -205,34 +182,37 @@ function FullStatistics({ selectedProject, onClearSelection, data, loading, erro
}, },
} }
if (!chartData) {
return (
<div className="flex justify-center items-center py-16">
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
</div>
)
}
return ( return (
<div> <div className="max-w-2xl mx-auto">
{onNavigate && ( {onNavigate && (
<div className="flex justify-end mb-4"> <button
<button onClick={() => onNavigate('current')}
onClick={() => onNavigate('current')} className="close-x-button"
className="flex items-center justify-center w-10 h-10 rounded-full bg-white hover:bg-gray-100 text-gray-600 hover:text-gray-800 border border-gray-200 hover:border-gray-300 transition-all duration-200 shadow-sm hover:shadow-md" title="Закрыть"
title="Закрыть" >
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> </button>
<line x1="18" y1="6" x2="6" y2="18"></line> )}
<line x1="6" y1="6" x2="18" y2="18"></line> {loading && !chartData ? (
</svg> <div className="fixed inset-0 flex justify-center items-center">
</button> <div className="flex flex-col items-center">
</div> <div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
) : !chartData ? (
<div className="flex justify-center items-center py-16">
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
</div>
) : (
<>
<div style={{ height: '550px', paddingTop: '100px' }}>
<Line data={chartData} options={chartOptions} />
</div>
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
</>
)} )}
<div style={{ height: '550px' }}>
<Line data={chartData} options={chartOptions} />
</div>
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
</div> </div>
) )
} }

View File

@@ -1,57 +0,0 @@
import React, { useState } from 'react'
import TodoistIntegration from './TodoistIntegration'
import TelegramIntegration from './TelegramIntegration'
function Integrations({ onNavigate }) {
const [selectedIntegration, setSelectedIntegration] = useState(null)
const integrations = [
{ id: 'todoist', name: 'TODOist' },
{ id: 'telegram', name: 'Telegram' },
]
if (selectedIntegration) {
if (selectedIntegration === 'todoist') {
return <TodoistIntegration onBack={() => setSelectedIntegration(null)} />
} else if (selectedIntegration === 'telegram') {
return <TelegramIntegration onBack={() => setSelectedIntegration(null)} />
}
}
return (
<div className="p-4 md:p-6">
<h1 className="text-2xl font-bold mb-6">Интеграции</h1>
<div className="space-y-4">
{integrations.map((integration) => (
<button
key={integration.id}
onClick={() => setSelectedIntegration(integration.id)}
className="w-full p-4 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow text-left border border-gray-200 hover:border-indigo-300"
>
<div className="flex items-center justify-between">
<span className="text-lg font-semibold text-gray-800">
{integration.name}
</span>
<svg
className="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
))}
</div>
</div>
)
}
export default Integrations

View File

@@ -0,0 +1,54 @@
.loading-error-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 80px; /* Отступ для нижнего бара */
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
}
/* Учитываем safe-area для мобильных устройств */
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.loading-error-container {
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
}
}
.loading-error-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 1rem;
}
.loading-error-text {
color: #374151;
font-size: 1.125rem;
font-weight: 500;
}
.loading-error-button {
padding: 0.75rem 1.5rem;
background: linear-gradient(to right, #4f46e5, #9333ea);
color: white;
border-radius: 0.5rem;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.loading-error-button:hover {
background: linear-gradient(to right, #4338ca, #7e22ce);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.loading-error-button:active {
transform: scale(0.98);
}

View File

@@ -0,0 +1,23 @@
import React from 'react'
import './LoadingError.css'
function LoadingError({ onRetry }) {
return (
<div className="loading-error-container">
<div className="loading-error-content">
<div className="loading-error-text">Ошибка, повторите позже</div>
{onRetry && (
<button
onClick={onRetry}
className="loading-error-button"
>
Повторить
</button>
)}
</div>
</div>
)
}
export default LoadingError

Some files were not shown because too many files have changed in this diff Show More