diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..48672d3 --- /dev/null +++ b/.env.test @@ -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/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: /api/integrations/todoist/oauth/callback +# - Webhooks callback 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 diff --git a/VERSION b/VERSION index 772ea92..fcdb2e1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.28.4 +4.0.0 diff --git a/play-life-backend/MIGRATION_BASELINE.md b/play-life-backend/MIGRATION_BASELINE.md new file mode 100644 index 0000000..5dcc0f1 --- /dev/null +++ b/play-life-backend/MIGRATION_BASELINE.md @@ -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 +``` + +Где `` - текущая версия миграции (обычно 1 для baseline). + +### Ошибка "no change" + +Если при применении миграций вы видите "no change", это нормально - база данных уже на актуальной версии. + +### Проблемы с путями к миграциям + +Убедитесь, что путь к миграциям правильный: +- Локально: `./play-life-backend/migrations` +- В Docker: `/migrations` + +Приложение автоматически проверяет оба пути. diff --git a/play-life-backend/MIGRATION_RISKS_AND_SOLUTIONS.md b/play-life-backend/MIGRATION_RISKS_AND_SOLUTIONS.md new file mode 100644 index 0000000..f208489 --- /dev/null +++ b/play-life-backend/MIGRATION_RISKS_AND_SOLUTIONS.md @@ -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 "Проверьте работу приложения" +``` diff --git a/play-life-backend/apply_baseline.sh b/play-life-backend/apply_baseline.sh new file mode 100755 index 0000000..3a6ee74 --- /dev/null +++ b/play-life-backend/apply_baseline.sh @@ -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 "Теперь приложение будет автоматически применять новые миграции при запуске." diff --git a/play-life-backend/go.mod b/play-life-backend/go.mod index d33d227..1edcd6c 100644 --- a/play-life-backend/go.mod +++ b/play-life-backend/go.mod @@ -1,17 +1,18 @@ module play-eng-backend -go 1.24 +go 1.24.0 require ( github.com/chromedp/chromedp v0.14.2 github.com/disintegration/imaging v1.6.2 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/golang-migrate/migrate/v4 v4.19.1 github.com/gorilla/mux v1.8.1 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 github.com/robfig/cron/v3 v3.0.1 - golang.org/x/crypto v0.28.0 + golang.org/x/crypto v0.45.0 ) require ( @@ -22,5 +23,5 @@ require ( 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.34.0 // indirect + golang.org/x/sys v0.38.0 // indirect ) diff --git a/play-life-backend/go.sum b/play-life-backend/go.sum index 296295b..23888dd 100644 --- a/play-life-backend/go.sum +++ b/play-life-backend/go.sum @@ -1,13 +1,39 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM= github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= @@ -16,8 +42,12 @@ github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -26,15 +56,43 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= -golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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= diff --git a/play-life-backend/main.go b/play-life-backend/main.go index cf91261..3f99531 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -30,6 +30,9 @@ import ( "github.com/disintegration/imaging" "github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/golang-jwt/jwt/v5" + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" "github.com/gorilla/mux" "github.com/joho/godotenv" _ "github.com/lib/pq" @@ -2580,1146 +2583,157 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } +// runMigrations applies database migrations using golang-migrate +func (a *App) runMigrations() error { + migrationsPath := "migrations" + if _, err := os.Stat(migrationsPath); os.IsNotExist(err) { + // Try alternative path for Docker + migrationsPath = "/migrations" + if _, err := os.Stat(migrationsPath); os.IsNotExist(err) { + return fmt.Errorf("migrations directory not found") + } + } + + // Get database connection string from environment + dbHost := getEnv("DB_HOST", "localhost") + dbPort := getEnv("DB_PORT", "5432") + dbUser := getEnv("DB_USER", "playeng") + dbPassword := getEnv("DB_PASSWORD", "playeng") + dbName := getEnv("DB_NAME", "playeng") + + databaseURL := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", + dbUser, dbPassword, dbHost, dbPort, dbName) + + // Create migrate instance + m, err := migrate.New( + fmt.Sprintf("file://%s", migrationsPath), + databaseURL, + ) + if err != nil { + return fmt.Errorf("failed to initialize migrations: %w", err) + } + defer m.Close() + + // Check if schema_migrations table exists and its state + var schemaExists bool + var currentVersion int64 + var isDirty bool + err = a.DB.QueryRow(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'schema_migrations' + ) + `).Scan(&schemaExists) + if err != nil { + log.Printf("Warning: Could not check schema_migrations table: %v", err) + } + + // If schema_migrations exists, check its state + if schemaExists { + err = a.DB.QueryRow(` + SELECT version, dirty FROM schema_migrations LIMIT 1 + `).Scan(¤tVersion, &isDirty) + if err != nil { + log.Printf("Warning: Could not read schema_migrations: %v", err) + schemaExists = false // Treat as if it doesn't exist + } else if isDirty { + // Database is in dirty state - fix it + log.Println("Detected dirty migration state, fixing...") + _, err = a.DB.Exec(` + UPDATE schema_migrations SET dirty = false WHERE version = $1 + `, currentVersion) + if err != nil { + return fmt.Errorf("failed to fix dirty migration state: %w", err) + } + log.Printf("Fixed dirty migration state for version %d", currentVersion) + // Continue to apply migrations normally + } + } + + // If schema_migrations doesn't exist, check if database has existing tables + // This handles the case when an old dump was restored + if !schemaExists { + var tableCount int + err = a.DB.QueryRow(` + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name NOT IN ('schema_migrations') + `).Scan(&tableCount) + if err == nil && tableCount > 0 { + // Database has existing tables but no schema_migrations + // This means an old dump was restored - set version to 1 without applying migration + log.Println("Detected existing database schema without schema_migrations table") + log.Println("Setting migration version to 1 (baseline) without applying migration") + + // Create schema_migrations table and set version to 1 + _, err = a.DB.Exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version bigint NOT NULL PRIMARY KEY, + dirty boolean NOT NULL + ) + `) + if err != nil { + return fmt.Errorf("failed to create schema_migrations table: %w", err) + } + + _, err = a.DB.Exec(` + INSERT INTO schema_migrations (version, dirty) + VALUES (1, false) + ON CONFLICT (version) DO UPDATE SET dirty = false + `) + if err != nil { + return fmt.Errorf("failed to set migration version: %w", err) + } + + log.Println("Migration version set to 1 (baseline) for existing database") + return nil + } + } + + // Apply migrations normally + if err := m.Up(); err != nil { + if err == migrate.ErrNoChange { + log.Println("Database is up to date, no migrations to apply") + return nil + } + return fmt.Errorf("failed to apply migrations: %w", err) + } + + log.Println("Database migrations applied successfully") + return nil +} + func (a *App) initDB() error { - createDictionariesTable := ` - CREATE TABLE IF NOT EXISTS dictionaries ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL - ) - ` - - createWordsTable := ` - CREATE TABLE IF NOT EXISTS words ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - translation TEXT NOT NULL, - description TEXT - ) - ` - - createProgressTable := ` - CREATE TABLE IF NOT EXISTS 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, - UNIQUE(word_id) - ) - ` - - createConfigsTable := ` - CREATE TABLE IF NOT EXISTS configs ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - words_count INTEGER NOT NULL, - max_cards INTEGER, - try_message TEXT - ) - ` - - createConfigDictionariesTable := ` - CREATE TABLE IF NOT EXISTS config_dictionaries ( - config_id INTEGER NOT NULL REFERENCES configs(id) ON DELETE CASCADE, - dictionary_id INTEGER NOT NULL REFERENCES dictionaries(id) ON DELETE CASCADE, - PRIMARY KEY (config_id, dictionary_id) - ) - ` - - createConfigDictionariesIndexes := []string{ - `CREATE INDEX IF NOT EXISTS idx_config_dictionaries_config_id ON config_dictionaries(config_id)`, - `CREATE INDEX IF NOT EXISTS idx_config_dictionaries_dictionary_id ON config_dictionaries(dictionary_id)`, - } - - // Alter existing table to make try_message nullable if it's not already - alterConfigsTable := ` - ALTER TABLE configs - ALTER COLUMN try_message DROP NOT NULL - ` - - // Alter existing table to add max_cards column if it doesn't exist - alterConfigsTableMaxCards := ` - ALTER TABLE configs - ADD COLUMN IF NOT EXISTS max_cards INTEGER - ` - - // Create dictionaries table first - if _, err := a.DB.Exec(createDictionariesTable); err != nil { - return err - } - - // Insert default dictionary "Все слова" with id = 0 - // PostgreSQL SERIAL starts from 1, so we need to set sequence to -1 first - insertDefaultDictionary := ` - 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 $$; - ` - if _, err := a.DB.Exec(insertDefaultDictionary); err != nil { - log.Printf("Warning: Failed to insert default dictionary: %v. Trying alternative method.", err) - // Alternative: try to insert without sequence manipulation - _, err2 := a.DB.Exec(`INSERT INTO dictionaries (id, name) VALUES (0, 'Все слова') ON CONFLICT (id) DO NOTHING`) - if err2 != nil { - log.Printf("Warning: Alternative insert also failed: %v", err2) - } - } - - if _, err := a.DB.Exec(createWordsTable); err != nil { - return err - } - - // Add dictionary_id column to words if it doesn't exist - // First check if column exists, if not add it - checkColumnExists := ` - SELECT COUNT(*) - FROM information_schema.columns - WHERE table_name='words' AND column_name='dictionary_id' - ` - var columnExists int - err := a.DB.QueryRow(checkColumnExists).Scan(&columnExists) - if err == nil && columnExists == 0 { - // Column doesn't exist, add it - alterWordsTable := ` - ALTER TABLE words - ADD COLUMN dictionary_id INTEGER DEFAULT 0 - ` - if _, err := a.DB.Exec(alterWordsTable); err != nil { - log.Printf("Warning: Failed to add dictionary_id column: %v", err) - } else { - // Add foreign key constraint - addForeignKey := ` - ALTER TABLE words - ADD CONSTRAINT words_dictionary_id_fkey - FOREIGN KEY (dictionary_id) REFERENCES dictionaries(id) - ` - a.DB.Exec(addForeignKey) - } - } - - // Update existing words to have dictionary_id = 0 - updateWordsDictionaryID := ` - UPDATE words - SET dictionary_id = 0 - WHERE dictionary_id IS NULL - ` - a.DB.Exec(updateWordsDictionaryID) - - // Make dictionary_id NOT NULL after setting default values (if column exists) - if columnExists > 0 || err == nil { - alterWordsTableNotNull := ` - DO $$ - BEGIN - ALTER TABLE words - ALTER COLUMN dictionary_id SET NOT NULL, - ALTER COLUMN dictionary_id SET DEFAULT 0; - EXCEPTION - WHEN others THEN - -- Ignore if already NOT NULL - NULL; - END $$; - ` - a.DB.Exec(alterWordsTableNotNull) - } - - // Create index on dictionary_id - createDictionaryIndex := ` - CREATE INDEX IF NOT EXISTS idx_words_dictionary_id ON words(dictionary_id) - ` - a.DB.Exec(createDictionaryIndex) - - // Remove unique constraint on words.name if it exists - removeUniqueConstraint := ` - ALTER TABLE words - DROP CONSTRAINT IF EXISTS words_name_key; - - ALTER TABLE words - DROP CONSTRAINT IF EXISTS words_name_unique; - ` - a.DB.Exec(removeUniqueConstraint) - - if _, err := a.DB.Exec(createProgressTable); err != nil { - return err - } - - if _, err := a.DB.Exec(createConfigsTable); err != nil { - return err - } - - // Try to alter existing table to make try_message nullable - // Ignore error if column is already nullable or table doesn't exist - a.DB.Exec(alterConfigsTable) - - // Try to alter existing table to add max_cards column - // Ignore error if column already exists - a.DB.Exec(alterConfigsTableMaxCards) - - // Create config_dictionaries table - if _, err := a.DB.Exec(createConfigDictionariesTable); err != nil { - return err - } - - // Create indexes for config_dictionaries - for _, indexSQL := range createConfigDictionariesIndexes { - if _, err := a.DB.Exec(indexSQL); err != nil { - log.Printf("Warning: Failed to create config_dictionaries index: %v", err) - } - } - + // This function is kept for backward compatibility but does nothing + // Database schema is now managed by golang-migrate return nil } func (a *App) initAuthDB() error { - // Create users table - createUsersTable := ` - 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 - ) - ` - if _, err := a.DB.Exec(createUsersTable); err != nil { - return err - } - - // Create index on email - a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)") - - // Create refresh_tokens table - createRefreshTokensTable := ` - 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 - ) - ` - if _, err := a.DB.Exec(createRefreshTokensTable); err != nil { - return err - } - - // Create indexes for refresh_tokens - a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id)") - a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash)") - - // Apply migration 014: Make refresh tokens permanent (expires_at nullable) - // This allows refresh tokens to never expire - var isNullable string - err := a.DB.QueryRow(` - SELECT is_nullable - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'refresh_tokens' - AND column_name = 'expires_at' - `).Scan(&isNullable) - if err == nil && isNullable == "NO" { - // Column is NOT NULL, need to make it nullable - if _, execErr := a.DB.Exec("ALTER TABLE refresh_tokens ALTER COLUMN expires_at DROP NOT NULL"); execErr != nil { - log.Printf("Warning: Failed to apply migration 014 (make expires_at nullable): %v", execErr) - } else { - log.Printf("Migration 014 applied: refresh_tokens.expires_at is now nullable") - } - } else if err == nil && isNullable == "YES" { - // Migration already applied - log.Printf("Migration 014 already applied (expires_at is nullable), skipping") - } - // If err != nil, column might not exist yet (shouldn't happen, but ignore) - - // Add user_id column to all tables if not exists - tables := []string{"projects", "entries", "nodes", "dictionaries", "words", "progress", "configs", "telegram_integrations", "weekly_goals", "tasks"} - for _, table := range tables { - alterSQL := fmt.Sprintf("ALTER TABLE %s ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE", table) - if _, err := a.DB.Exec(alterSQL); err != nil { - log.Printf("Warning: Failed to add user_id to %s: %v", table, err) - } - indexSQL := fmt.Sprintf("CREATE INDEX IF NOT EXISTS idx_%s_user_id ON %s(user_id)", table, table) - a.DB.Exec(indexSQL) - } - - // Drop old unique constraint on projects.name (now unique per user, not globally) - a.DB.Exec("ALTER TABLE projects DROP CONSTRAINT IF EXISTS unique_project_name") - - // Drop old unique constraint on progress.word_id (now unique per user) - a.DB.Exec("ALTER TABLE progress DROP CONSTRAINT IF EXISTS progress_word_id_key") - - // Create new unique constraint per user for progress - a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_progress_word_user_unique ON progress(word_id, user_id)") - - // Add webhook_token to telegram_integrations for URL-based user identification (legacy, will be removed in migration 012) - a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS webhook_token VARCHAR(255)") - a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_webhook_token ON telegram_integrations(webhook_token) WHERE webhook_token IS NOT NULL") - - // Apply migration 012: Refactor telegram_integrations for single shared bot - if err := a.applyMigration012(); err != nil { - log.Printf("Warning: Failed to apply migration 012: %v", err) - // Не возвращаем ошибку, чтобы приложение могло запуститься - } - - // Apply migration 013: Refactor todoist_integrations for single Todoist app - if err := a.applyMigration013(); err != nil { - log.Printf("Warning: Failed to apply migration 013: %v", err) - // Не возвращаем ошибку, чтобы приложение могло запуститься - } - - // Apply migration 019: Add wishlist tables - if err := a.applyMigration019(); err != nil { - log.Printf("Warning: Failed to apply migration 019: %v", err) - // Не возвращаем ошибку, чтобы приложение могло запуститься - } - - // Apply migration 020: Change period_type to start_date in score_conditions - if err := a.applyMigration020(); err != nil { - log.Printf("Warning: Failed to apply migration 020: %v", err) - // Не возвращаем ошибку, чтобы приложение могло запуститься - } - - // Apply migration 021: Add wishlist_id to tasks - if err := a.applyMigration021(); err != nil { - log.Printf("Warning: Failed to apply migration 021: %v", err) - // Не возвращаем ошибку, чтобы приложение могло запуститься - } - - // Apply migration 022: Refactor configs to link with tasks - if err := a.applyMigration022(); err != nil { - log.Printf("Warning: Failed to apply migration 022: %v", err) - // Не возвращаем ошибку, чтобы приложение могло запуститься - } - - // Apply migration 023: Add wishlist boards - if err := a.applyMigration023(); err != nil { - log.Printf("Warning: Failed to apply migration 023: %v", err) - // Не возвращаем ошибку, чтобы приложение могло запуститься - } - - // Apply migration 024: Add reward_policy to tasks - if err := a.applyMigration024(); err != nil { - log.Printf("Warning: Failed to apply migration 024: %v", err) - // Не возвращаем ошибку, чтобы приложение могло запуститься - } - - // Apply migration 025: Remove wishlist conditions without user_id - if err := a.applyMigration025(); err != nil { - log.Printf("Warning: Failed to apply migration 025: %v", err) - // Не возвращаем ошибку, чтобы приложение могло запуститься - } - - // Apply migration 028: Optimize task queries with composite index - if err := a.applyMigration028(); err != nil { - log.Printf("Warning: Failed to apply migration 028: %v", err) - // Не возвращаем ошибку, чтобы приложение могло запуститься - } - - // Apply migration 029: Add covering indexes - if err := a.applyMigration029(); err != nil { - log.Printf("Warning: Failed to apply migration 029: %v", err) - // Не возвращаем ошибку, чтобы приложение могло запуститься - } - // Clean up expired refresh tokens (only those with expiration date set) + // This is business logic that should run on startup a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()") - - return nil -} - -// applyMigration012 применяет миграцию 012_refactor_telegram_single_bot.sql -func (a *App) applyMigration012() error { - log.Printf("Applying migration 012: Refactor telegram_integrations for single shared bot") - - // 1. Создаем таблицу todoist_integrations - createTodoistIntegrationsTable := ` - 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) - ) - ` - if _, err := a.DB.Exec(createTodoistIntegrationsTable); err != nil { - return fmt.Errorf("failed to create todoist_integrations table: %w", err) - } - - // Создаем индексы для todoist_integrations - a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_webhook_token ON todoist_integrations(webhook_token)") - a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_todoist_integrations_user_id ON todoist_integrations(user_id)") - - // 2. Мигрируем webhook_token из telegram_integrations в todoist_integrations - migrateWebhookTokens := ` - 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 - ` - if _, err := a.DB.Exec(migrateWebhookTokens); err != nil { - log.Printf("Warning: Failed to migrate webhook_token to todoist_integrations: %v", err) - // Продолжаем выполнение, так как это может быть уже выполнено - } - - // 3. Удаляем bot_token (будет в .env) - a.DB.Exec("ALTER TABLE telegram_integrations DROP COLUMN IF EXISTS bot_token") - - // 4. Удаляем webhook_token (перенесли в todoist_integrations) - a.DB.Exec("ALTER TABLE telegram_integrations DROP COLUMN IF EXISTS webhook_token") - - // 5. Добавляем telegram_user_id - a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS telegram_user_id BIGINT") - - // 6. Добавляем start_token - a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS start_token VARCHAR(255)") - - // 7. Добавляем timestamps если их нет - a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP") - a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP") - - // 8. Создаем индексы - a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_start_token ON telegram_integrations(start_token) WHERE start_token IS NOT NULL") - a.DB.Exec("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 - a.DB.Exec("DROP INDEX IF EXISTS idx_telegram_integrations_user_id") - a.DB.Exec("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 - a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_telegram_integrations_chat_id ON telegram_integrations(chat_id) WHERE chat_id IS NOT NULL") - - // Удаляем старый индекс webhook_token - a.DB.Exec("DROP INDEX IF EXISTS idx_telegram_integrations_webhook_token") - - // 9. Очищаем данные Telegram для переподключения (только если еще не очищены) - // Проверяем, есть ли записи с заполненными chat_id или telegram_user_id - var count int - err := a.DB.QueryRow(` - SELECT COUNT(*) FROM telegram_integrations - WHERE (chat_id IS NOT NULL OR telegram_user_id IS NOT NULL) - AND (start_token IS NULL OR start_token = '') - `).Scan(&count) - - // Если есть старые данные без start_token, очищаем их для переподключения - if err == nil && count > 0 { - log.Printf("Clearing old Telegram integration data for %d users (they will need to reconnect)", count) - a.DB.Exec(` - UPDATE telegram_integrations - SET chat_id = NULL, - telegram_user_id = NULL, - start_token = NULL, - updated_at = CURRENT_TIMESTAMP - WHERE (chat_id IS NOT NULL OR telegram_user_id IS NOT NULL) - AND (start_token IS NULL OR start_token = '') - `) - } - - log.Printf("Migration 012 applied successfully") - return nil -} - -// applyMigration013 применяет миграцию 013_refactor_todoist_single_app.sql -func (a *App) applyMigration013() error { - log.Printf("Applying migration 013: Refactor todoist_integrations for single Todoist app") - - // 1. Добавляем новые поля - a.DB.Exec("ALTER TABLE todoist_integrations ADD COLUMN IF NOT EXISTS todoist_user_id BIGINT") - a.DB.Exec("ALTER TABLE todoist_integrations ADD COLUMN IF NOT EXISTS todoist_email VARCHAR(255)") - a.DB.Exec("ALTER TABLE todoist_integrations ADD COLUMN IF NOT EXISTS access_token TEXT") - - // 2. Удаляем webhook_token - a.DB.Exec("ALTER TABLE todoist_integrations DROP COLUMN IF EXISTS webhook_token") - - // 3. Удаляем старый индекс - a.DB.Exec("DROP INDEX IF EXISTS idx_todoist_integrations_webhook_token") - - // 4. Создаем новые индексы - a.DB.Exec(` - 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 - `) - a.DB.Exec(` - CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_email - ON todoist_integrations(todoist_email) - WHERE todoist_email IS NOT NULL - `) - - log.Printf("Migration 013 applied successfully") - return nil -} - -// applyMigration019 применяет миграцию 019_add_wishlist.sql -func (a *App) applyMigration019() error { - log.Printf("Applying migration 019: Add wishlist tables") - - // Проверяем, существует ли уже таблица wishlist_items - var exists bool - err := a.DB.QueryRow(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = 'wishlist_items' - ) - `).Scan(&exists) - if err != nil { - return fmt.Errorf("failed to check if wishlist_items exists: %w", err) - } - if exists { - log.Printf("Migration 019 already applied (wishlist_items table exists), skipping") - return nil - } - - // Читаем SQL файл миграции - migrationPath := "/migrations/019_add_wishlist.sql" - if _, err := os.Stat(migrationPath); os.IsNotExist(err) { - // Пробуем альтернативный путь (для локальной разработки) - migrationPath = "play-life-backend/migrations/019_add_wishlist.sql" - if _, err := os.Stat(migrationPath); os.IsNotExist(err) { - migrationPath = "migrations/019_add_wishlist.sql" - } - } - - migrationSQL, err := os.ReadFile(migrationPath) - if err != nil { - return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err) - } - - // Выполняем миграцию - if _, err := a.DB.Exec(string(migrationSQL)); err != nil { - return fmt.Errorf("failed to execute migration 019: %w", err) - } - - log.Printf("Migration 019 applied successfully") - return nil -} - -// applyMigration020 применяет миграцию 020_change_period_to_start_date.sql -func (a *App) applyMigration020() error { - log.Printf("Applying migration 020: Change period_type to start_date in score_conditions") - - // Проверяем, существует ли уже поле start_date - var exists bool - err := a.DB.QueryRow(` - SELECT EXISTS ( - SELECT FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'score_conditions' - AND column_name = 'start_date' - ) - `).Scan(&exists) - if err != nil { - return fmt.Errorf("failed to check if start_date exists: %w", err) - } - if exists { - log.Printf("Migration 020 already applied (start_date column exists), skipping") - return nil - } - - // Читаем SQL файл миграции - migrationPath := "/migrations/020_change_period_to_start_date.sql" - if _, err := os.Stat(migrationPath); os.IsNotExist(err) { - // Пробуем альтернативный путь (для локальной разработки) - migrationPath = "play-life-backend/migrations/020_change_period_to_start_date.sql" - if _, err := os.Stat(migrationPath); os.IsNotExist(err) { - migrationPath = "migrations/020_change_period_to_start_date.sql" - } - } - - migrationSQL, err := os.ReadFile(migrationPath) - if err != nil { - return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err) - } - - // Выполняем миграцию - if _, err := a.DB.Exec(string(migrationSQL)); err != nil { - return fmt.Errorf("failed to execute migration 020: %w", err) - } - - log.Printf("Migration 020 applied successfully") - return nil -} - -// applyMigration021 применяет миграцию 021_add_wishlist_id_to_tasks.sql -func (a *App) applyMigration021() error { - log.Printf("Applying migration 021: Add wishlist_id to tasks") - - // Проверяем, существует ли уже поле wishlist_id - var exists bool - err := a.DB.QueryRow(` - SELECT EXISTS ( - SELECT FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'tasks' - AND column_name = 'wishlist_id' - ) - `).Scan(&exists) - if err != nil { - return fmt.Errorf("failed to check if wishlist_id exists: %w", err) - } - if exists { - log.Printf("Migration 021 already applied (wishlist_id column exists), skipping") - return nil - } - - // Читаем SQL файл миграции - migrationPath := "/migrations/021_add_wishlist_id_to_tasks.sql" - if _, err := os.Stat(migrationPath); os.IsNotExist(err) { - // Пробуем альтернативный путь (для локальной разработки) - migrationPath = "play-life-backend/migrations/021_add_wishlist_id_to_tasks.sql" - if _, err := os.Stat(migrationPath); os.IsNotExist(err) { - migrationPath = "migrations/021_add_wishlist_id_to_tasks.sql" - } - } - - migrationSQL, err := os.ReadFile(migrationPath) - if err != nil { - return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err) - } - - // Выполняем миграцию - if _, err := a.DB.Exec(string(migrationSQL)); err != nil { - return fmt.Errorf("failed to execute migration 021: %w", err) - } - - log.Printf("Migration 021 applied successfully") - return nil -} - -// applyMigration022 применяет миграцию 022_refactor_configs_to_tasks.sql -func (a *App) applyMigration022() error { - log.Printf("Applying migration 022: Refactor configs to link with tasks") - - // Проверяем, существует ли уже поле config_id в tasks - var exists bool - err := a.DB.QueryRow(` - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_name = 'tasks' - AND column_name = 'config_id' - ) - `).Scan(&exists) - - if err != nil { - return fmt.Errorf("failed to check config_id column existence: %w", err) - } - - if exists { - log.Printf("Migration 022 already applied (config_id column exists), skipping") - return nil - } - - // Читаем SQL из файла миграции - migrationPath := "migrations/022_refactor_configs_to_tasks.sql" - if _, err := os.Stat(migrationPath); os.IsNotExist(err) { - // Пробуем альтернативный путь (в Docker) - migrationPath = "/migrations/022_refactor_configs_to_tasks.sql" - } - - migrationSQL, err := os.ReadFile(migrationPath) - if err != nil { - return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err) - } - - // Выполняем миграцию - if _, err := a.DB.Exec(string(migrationSQL)); err != nil { - return fmt.Errorf("failed to execute migration 022: %w", err) - } - - log.Printf("Migration 022 applied successfully") - return nil -} - -// applyMigration023 применяет миграцию 023_add_wishlist_boards.sql -func (a *App) applyMigration023() error { - log.Printf("Applying migration 023: Add wishlist boards") - - // Проверяем, существует ли уже таблица wishlist_boards - var exists bool - err := a.DB.QueryRow(` - SELECT EXISTS ( - SELECT FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = 'wishlist_boards' - ) - `).Scan(&exists) - - if err != nil { - return fmt.Errorf("failed to check wishlist_boards table existence: %w", err) - } - - if exists { - log.Printf("Migration 023 already applied (wishlist_boards table exists), skipping") - return nil - } - - // Читаем SQL из файла миграции - migrationPath := "migrations/023_add_wishlist_boards.sql" - if _, err := os.Stat(migrationPath); os.IsNotExist(err) { - // Пробуем альтернативный путь (в Docker) - migrationPath = "/migrations/023_add_wishlist_boards.sql" - } - - migrationSQL, err := os.ReadFile(migrationPath) - if err != nil { - return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err) - } - - // Выполняем миграцию - if _, err := a.DB.Exec(string(migrationSQL)); err != nil { - return fmt.Errorf("failed to execute migration 023: %w", err) - } - - log.Printf("Migration 023 applied successfully") - return nil -} - -// applyMigration024 применяет миграцию 024_add_reward_policy.sql -func (a *App) applyMigration024() error { - log.Printf("Applying migration 024: Add reward_policy to tasks") - - // Проверяем, существует ли уже колонка reward_policy - var exists bool - err := a.DB.QueryRow(` - SELECT EXISTS ( - SELECT FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name = 'tasks' - AND column_name = 'reward_policy' - ) - `).Scan(&exists) - - if err != nil { - return fmt.Errorf("failed to check reward_policy column existence: %w", err) - } - - if exists { - log.Printf("Migration 024 already applied (reward_policy column exists), skipping") - return nil - } - - // Читаем SQL из файла миграции - migrationPath := "migrations/024_add_reward_policy.sql" - if _, err := os.Stat(migrationPath); os.IsNotExist(err) { - // Пробуем альтернативный путь (в Docker) - migrationPath = "/migrations/024_add_reward_policy.sql" - } - - migrationSQL, err := os.ReadFile(migrationPath) - if err != nil { - return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err) - } - - // Выполняем миграцию - if _, err := a.DB.Exec(string(migrationSQL)); err != nil { - return fmt.Errorf("failed to execute migration 024: %w", err) - } - - log.Printf("Migration 024 applied successfully") - return nil -} - -// applyMigration025 применяет миграцию 025_remove_conditions_without_user_id.sql -func (a *App) applyMigration025() error { - log.Printf("Applying migration 025: Remove wishlist conditions without user_id") - - // Проверяем, есть ли условия без user_id - var count int - err := a.DB.QueryRow(` - SELECT COUNT(*) - FROM wishlist_conditions - WHERE user_id IS NULL - `).Scan(&count) - - if err != nil { - return fmt.Errorf("failed to check conditions without user_id: %w", err) - } - - if count == 0 { - log.Printf("Migration 025 already applied (no conditions without user_id), skipping") - return nil - } - - log.Printf("Found %d conditions without user_id, removing them", count) - - // Читаем SQL из файла миграции - migrationPath := "migrations/025_remove_conditions_without_user_id.sql" - if _, err := os.Stat(migrationPath); os.IsNotExist(err) { - // Пробуем альтернативный путь (в Docker) - migrationPath = "/migrations/025_remove_conditions_without_user_id.sql" - } - - migrationSQL, err := os.ReadFile(migrationPath) - if err != nil { - return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err) - } - - // Выполняем миграцию - if _, err := a.DB.Exec(string(migrationSQL)); err != nil { - return fmt.Errorf("failed to execute migration 025: %w", err) - } - - log.Printf("Migration 025 applied successfully, removed %d conditions without user_id", count) - return nil -} - -// applyMigration028 применяет миграцию 028_optimize_task_queries.sql -func (a *App) applyMigration028() error { - log.Printf("Applying migration 028: Optimize task queries with composite index") - - // Проверяем, существует ли уже индекс - var exists bool - err := a.DB.QueryRow(` - SELECT EXISTS ( - SELECT FROM pg_indexes - WHERE schemaname = 'public' - AND indexname = 'idx_tasks_id_user_deleted' - ) - `).Scan(&exists) - if err != nil { - return fmt.Errorf("failed to check if index exists: %w", err) - } - if exists { - log.Printf("Migration 028 already applied (index idx_tasks_id_user_deleted exists), skipping") - return nil - } - - // Читаем SQL файл миграции - migrationPath := "/migrations/028_optimize_task_queries.sql" - if _, err := os.Stat(migrationPath); os.IsNotExist(err) { - // Пробуем альтернативный путь (для локальной разработки) - migrationPath = "play-life-backend/migrations/028_optimize_task_queries.sql" - if _, err := os.Stat(migrationPath); os.IsNotExist(err) { - migrationPath = "migrations/028_optimize_task_queries.sql" - } - } - - migrationSQL, err := os.ReadFile(migrationPath) - if err != nil { - return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err) - } - - // Выполняем миграцию - if _, err := a.DB.Exec(string(migrationSQL)); err != nil { - return fmt.Errorf("failed to execute migration 028: %w", err) - } - - log.Printf("Migration 028 applied successfully") - return nil -} - -// applyMigration029 применяет миграцию 029_add_covering_indexes.sql -func (a *App) applyMigration029() error { - log.Printf("Applying migration 029: Add covering indexes") - - // Проверяем, существует ли уже индекс для подзадач - var exists bool - err := a.DB.QueryRow(` - SELECT EXISTS ( - SELECT FROM pg_indexes - WHERE schemaname = 'public' - AND indexname = 'idx_tasks_parent_deleted_covering' - ) - `).Scan(&exists) - if err != nil { - return fmt.Errorf("failed to check if index exists: %w", err) - } - if exists { - log.Printf("Migration 029 already applied (covering indexes exist), skipping") - return nil - } - - // Читаем SQL файл миграции - migrationPath := "/migrations/029_add_covering_indexes.sql" - if _, err := os.Stat(migrationPath); os.IsNotExist(err) { - // Пробуем альтернативный путь (для локальной разработки) - migrationPath = "play-life-backend/migrations/029_add_covering_indexes.sql" - if _, err := os.Stat(migrationPath); os.IsNotExist(err) { - migrationPath = "migrations/029_add_covering_indexes.sql" - } - } - - migrationSQL, err := os.ReadFile(migrationPath) - if err != nil { - return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err) - } - - // Выполняем миграцию - if _, err := a.DB.Exec(string(migrationSQL)); err != nil { - return fmt.Errorf("failed to execute migration 029: %w", err) - } - - log.Printf("Migration 029 applied successfully") return nil } func (a *App) initPlayLifeDB() error { - // Создаем таблицу projects - createProjectsTable := ` - CREATE TABLE IF NOT EXISTS projects ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - priority SMALLINT, - CONSTRAINT unique_project_name UNIQUE (name) - ) - ` + // This function is kept for backward compatibility but does nothing + // Database schema is now managed by golang-migrate + return nil +} - // Создаем таблицу entries - createEntriesTable := ` - CREATE TABLE IF NOT EXISTS entries ( - id SERIAL PRIMARY KEY, - text TEXT NOT NULL, - created_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP - ) - ` - - // Создаем таблицу nodes - createNodesTable := ` - CREATE TABLE IF NOT EXISTS nodes ( - id SERIAL PRIMARY KEY, - project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, - score NUMERIC(8,4) - ) - ` - - // Создаем индексы для nodes - createNodesIndexes := []string{ - `CREATE INDEX IF NOT EXISTS idx_nodes_project_id ON nodes(project_id)`, - `CREATE INDEX IF NOT EXISTS idx_nodes_entry_id ON nodes(entry_id)`, - } - - // Создаем таблицу weekly_goals - createWeeklyGoalsTable := ` - CREATE TABLE IF NOT EXISTS weekly_goals ( - id SERIAL PRIMARY KEY, - project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, - goal_year INTEGER NOT NULL, - goal_week INTEGER NOT NULL, - min_goal_score NUMERIC(10,4) NOT NULL DEFAULT 0, - max_goal_score NUMERIC(10,4), - max_score NUMERIC(10,4), - priority SMALLINT, - CONSTRAINT weekly_goals_project_id_goal_year_goal_week_key UNIQUE (project_id, goal_year, goal_week) - ) - ` - - // Создаем индекс для weekly_goals - createWeeklyGoalsIndex := ` - CREATE INDEX IF NOT EXISTS idx_weekly_goals_project_id ON weekly_goals(project_id) - ` - - // Выполняем создание таблиц - if _, err := a.DB.Exec(createProjectsTable); err != nil { - return fmt.Errorf("failed to create projects table: %w", err) - } - - // Добавляем колонку deleted, если её нет (для существующих баз) - alterProjectsTable := ` - ALTER TABLE projects - ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE - ` - if _, err := a.DB.Exec(alterProjectsTable); err != nil { - log.Printf("Warning: Failed to add deleted column to projects table: %v", err) - } - - // Создаем индекс на deleted - createProjectsDeletedIndex := ` - CREATE INDEX IF NOT EXISTS idx_projects_deleted ON projects(deleted) - ` - if _, err := a.DB.Exec(createProjectsDeletedIndex); err != nil { - log.Printf("Warning: Failed to create projects deleted index: %v", err) - } - - if _, err := a.DB.Exec(createEntriesTable); err != nil { - return fmt.Errorf("failed to create entries table: %w", err) - } - - if _, err := a.DB.Exec(createNodesTable); err != nil { - return fmt.Errorf("failed to create nodes table: %w", err) - } - - for _, indexSQL := range createNodesIndexes { - if _, err := a.DB.Exec(indexSQL); err != nil { - log.Printf("Warning: Failed to create index: %v", err) - } - } - - if _, err := a.DB.Exec(createWeeklyGoalsTable); err != nil { - return fmt.Errorf("failed to create weekly_goals table: %w", err) - } - - // Авто-миграция weekly_goals: убираем неиспользуемый actual_score и добавляем max_score (snapshot) - // Делаем через ALTER, чтобы работало на уже существующих БД без ручного прогона SQL-миграций. - a.DB.Exec("ALTER TABLE weekly_goals DROP COLUMN IF EXISTS actual_score") - a.DB.Exec("ALTER TABLE weekly_goals ADD COLUMN IF NOT EXISTS max_score NUMERIC(10,4)") - - if _, err := a.DB.Exec(createWeeklyGoalsIndex); err != nil { - log.Printf("Warning: Failed to create weekly_goals index: %v", err) - } - - // Создаем materialized view (может потребоваться удаление старого, если он существует) - dropMaterializedView := `DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv` - a.DB.Exec(dropMaterializedView) // Игнорируем ошибку, если view не существует - - createMaterializedView := ` - 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 - ` - - if _, err := a.DB.Exec(createMaterializedView); err != nil { - return fmt.Errorf("failed to create weekly_report_mv: %w", err) - } - - // Создаем индекс для materialized view - createMVIndex := ` - CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week - ON weekly_report_mv(project_id, report_year, report_week) - ` - if _, err := a.DB.Exec(createMVIndex); err != nil { - log.Printf("Warning: Failed to create materialized view index: %v", err) - } - - // Создаем таблицу telegram_integrations - createTelegramIntegrationsTable := ` - CREATE TABLE IF NOT EXISTS telegram_integrations ( - id SERIAL PRIMARY KEY, - chat_id VARCHAR(255), - bot_token VARCHAR(255) - ) - ` - if _, err := a.DB.Exec(createTelegramIntegrationsTable); err != nil { - return fmt.Errorf("failed to create telegram_integrations table: %w", err) - } - - // Создаем таблицу tasks - createTasksTable := ` - 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 - ) - ` - if _, err := a.DB.Exec(createTasksTable); err != nil { - return fmt.Errorf("failed to create tasks table: %w", err) - } - - // Создаем индексы для tasks - createTasksIndexes := []string{ - `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)`, - } - for _, indexSQL := range createTasksIndexes { - if _, err := a.DB.Exec(indexSQL); err != nil { - log.Printf("Warning: Failed to create tasks index: %v", err) - } - } - - // Apply migration 016: Add repetition_period to tasks - if _, err := a.DB.Exec("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS repetition_period INTERVAL"); err != nil { - log.Printf("Warning: Failed to apply migration 016 (add repetition_period): %v", err) - } - - // Apply migration 017: Add next_show_at to tasks - if _, err := a.DB.Exec("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS next_show_at TIMESTAMP WITH TIME ZONE"); err != nil { - log.Printf("Warning: Failed to apply migration 017 (add next_show_at): %v", err) - } - - // Apply migration 018: Add repetition_date to tasks - if _, err := a.DB.Exec("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS repetition_date TEXT"); err != nil { - log.Printf("Warning: Failed to apply migration 018 (add repetition_date): %v", err) - } - - // Создаем таблицу reward_configs - createRewardConfigsTable := ` - 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 - ) - ` - if _, err := a.DB.Exec(createRewardConfigsTable); err != nil { - return fmt.Errorf("failed to create reward_configs table: %w", err) - } - - // Создаем индексы для reward_configs - createRewardConfigsIndexes := []string{ - `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)`, - } - for _, indexSQL := range createRewardConfigsIndexes { - if _, err := a.DB.Exec(indexSQL); err != nil { - log.Printf("Warning: Failed to create reward_configs index: %v", err) - } - } +// DEPRECATED: All migration functions below are no longer used +// Database migrations are now handled by golang-migrate +// These functions are kept for reference only and will be removed in future versions +// +// NOTE: Functions applyMigration012-029 have been removed as they are no longer needed. +// All database schema is now managed by golang-migrate baseline migration. +// DEPRECATED: initPlayLifeDBOld is no longer used - schema is managed by golang-migrate +func (a *App) initPlayLifeDBOld() error { + // This function is kept for backward compatibility but does nothing + // Database schema is now managed by golang-migrate return nil } @@ -3758,17 +2772,14 @@ func (a *App) startWeeklyGoalsScheduler() { log.Printf("Error in scheduled weekly goals setup: %v", err) } }) - if err != nil { - log.Printf("Error adding cron job for weekly goals: %v", err) + log.Printf("Warning: Failed to add weekly goals scheduler: %v", err) return } // Запускаем планировщик c.Start() - log.Printf("Weekly goals scheduler started: every Monday at 6:00 AM %s", timezoneStr) - - // Планировщик будет работать в фоновом режиме + log.Println("Weekly goals scheduler started") } // getWeeklyStatsData получает данные о проектах и их целях (без HTTP обработки) @@ -4320,23 +3331,11 @@ func main() { log.Printf("WARNING: TELEGRAM_BOT_TOKEN not set in environment") } - // Инициализируем БД для play-life проекта - if err := app.initPlayLifeDB(); err != nil { - log.Fatal("Failed to initialize play-life database:", err) + // Apply database migrations + if err := app.runMigrations(); err != nil { + log.Fatal("Failed to apply database migrations:", err) } - log.Println("Play-life database initialized successfully") - - // Инициализируем БД для слов, словарей и конфигураций - if err := app.initDB(); err != nil { - log.Fatal("Failed to initialize words/dictionaries database:", err) - } - log.Println("Words/dictionaries database initialized successfully") - - // Инициализируем таблицы для авторизации - if err := app.initAuthDB(); err != nil { - log.Fatal("Failed to initialize auth database:", err) - } - log.Println("Auth database initialized successfully") + log.Println("Database migrations applied successfully") // Запускаем планировщик для автоматической фиксации целей на неделю app.startWeeklyGoalsScheduler() diff --git a/play-life-backend/migrations/000001_baseline.down.sql b/play-life-backend/migrations/000001_baseline.down.sql new file mode 100644 index 0000000..1e67506 --- /dev/null +++ b/play-life-backend/migrations/000001_baseline.down.sql @@ -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 diff --git a/play-life-backend/migrations/000001_baseline.up.sql b/play-life-backend/migrations/000001_baseline.up.sql new file mode 100644 index 0000000..b5300cd --- /dev/null +++ b/play-life-backend/migrations/000001_baseline.up.sql @@ -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); diff --git a/play-life-backend/migrations/001_create_schema.sql b/play-life-backend/migrations_old/001_create_schema.sql similarity index 100% rename from play-life-backend/migrations/001_create_schema.sql rename to play-life-backend/migrations_old/001_create_schema.sql diff --git a/play-life-backend/migrations/002_add_dictionaries.sql b/play-life-backend/migrations_old/002_add_dictionaries.sql similarity index 100% rename from play-life-backend/migrations/002_add_dictionaries.sql rename to play-life-backend/migrations_old/002_add_dictionaries.sql diff --git a/play-life-backend/migrations/003_remove_words_unique_constraint.sql b/play-life-backend/migrations_old/003_remove_words_unique_constraint.sql similarity index 100% rename from play-life-backend/migrations/003_remove_words_unique_constraint.sql rename to play-life-backend/migrations_old/003_remove_words_unique_constraint.sql diff --git a/play-life-backend/migrations/004_add_config_dictionaries.sql b/play-life-backend/migrations_old/004_add_config_dictionaries.sql similarity index 100% rename from play-life-backend/migrations/004_add_config_dictionaries.sql rename to play-life-backend/migrations_old/004_add_config_dictionaries.sql diff --git a/play-life-backend/migrations/005_fix_weekly_report_mv.sql b/play-life-backend/migrations_old/005_fix_weekly_report_mv.sql similarity index 100% rename from play-life-backend/migrations/005_fix_weekly_report_mv.sql rename to play-life-backend/migrations_old/005_fix_weekly_report_mv.sql diff --git a/play-life-backend/migrations/006_fix_weekly_report_mv_structure.sql b/play-life-backend/migrations_old/006_fix_weekly_report_mv_structure.sql similarity index 100% rename from play-life-backend/migrations/006_fix_weekly_report_mv_structure.sql rename to play-life-backend/migrations_old/006_fix_weekly_report_mv_structure.sql diff --git a/play-life-backend/migrations/007_add_deleted_to_projects.sql b/play-life-backend/migrations_old/007_add_deleted_to_projects.sql similarity index 100% rename from play-life-backend/migrations/007_add_deleted_to_projects.sql rename to play-life-backend/migrations_old/007_add_deleted_to_projects.sql diff --git a/play-life-backend/migrations/008_add_telegram_integrations.sql b/play-life-backend/migrations_old/008_add_telegram_integrations.sql similarity index 100% rename from play-life-backend/migrations/008_add_telegram_integrations.sql rename to play-life-backend/migrations_old/008_add_telegram_integrations.sql diff --git a/play-life-backend/migrations/009_add_users_and_multitenancy.sql b/play-life-backend/migrations_old/009_add_users_and_multitenancy.sql similarity index 100% rename from play-life-backend/migrations/009_add_users_and_multitenancy.sql rename to play-life-backend/migrations_old/009_add_users_and_multitenancy.sql diff --git a/play-life-backend/migrations/011_add_webhook_tokens.sql b/play-life-backend/migrations_old/011_add_webhook_tokens.sql similarity index 100% rename from play-life-backend/migrations/011_add_webhook_tokens.sql rename to play-life-backend/migrations_old/011_add_webhook_tokens.sql diff --git a/play-life-backend/migrations/012_refactor_telegram_single_bot.sql b/play-life-backend/migrations_old/012_refactor_telegram_single_bot.sql similarity index 100% rename from play-life-backend/migrations/012_refactor_telegram_single_bot.sql rename to play-life-backend/migrations_old/012_refactor_telegram_single_bot.sql diff --git a/play-life-backend/migrations/013_refactor_todoist_single_app.sql b/play-life-backend/migrations_old/013_refactor_todoist_single_app.sql similarity index 100% rename from play-life-backend/migrations/013_refactor_todoist_single_app.sql rename to play-life-backend/migrations_old/013_refactor_todoist_single_app.sql diff --git a/play-life-backend/migrations/014_make_refresh_tokens_permanent.sql b/play-life-backend/migrations_old/014_make_refresh_tokens_permanent.sql similarity index 100% rename from play-life-backend/migrations/014_make_refresh_tokens_permanent.sql rename to play-life-backend/migrations_old/014_make_refresh_tokens_permanent.sql diff --git a/play-life-backend/migrations/015_add_tasks.sql b/play-life-backend/migrations_old/015_add_tasks.sql similarity index 100% rename from play-life-backend/migrations/015_add_tasks.sql rename to play-life-backend/migrations_old/015_add_tasks.sql diff --git a/play-life-backend/migrations/016_add_repetition_period.sql b/play-life-backend/migrations_old/016_add_repetition_period.sql similarity index 100% rename from play-life-backend/migrations/016_add_repetition_period.sql rename to play-life-backend/migrations_old/016_add_repetition_period.sql diff --git a/play-life-backend/migrations/017_add_next_show_at.sql b/play-life-backend/migrations_old/017_add_next_show_at.sql similarity index 100% rename from play-life-backend/migrations/017_add_next_show_at.sql rename to play-life-backend/migrations_old/017_add_next_show_at.sql diff --git a/play-life-backend/migrations/018_add_repetition_date.sql b/play-life-backend/migrations_old/018_add_repetition_date.sql similarity index 100% rename from play-life-backend/migrations/018_add_repetition_date.sql rename to play-life-backend/migrations_old/018_add_repetition_date.sql diff --git a/play-life-backend/migrations/019_add_wishlist.sql b/play-life-backend/migrations_old/019_add_wishlist.sql similarity index 100% rename from play-life-backend/migrations/019_add_wishlist.sql rename to play-life-backend/migrations_old/019_add_wishlist.sql diff --git a/play-life-backend/migrations/020_change_period_to_start_date.sql b/play-life-backend/migrations_old/020_change_period_to_start_date.sql similarity index 100% rename from play-life-backend/migrations/020_change_period_to_start_date.sql rename to play-life-backend/migrations_old/020_change_period_to_start_date.sql diff --git a/play-life-backend/migrations/021_add_wishlist_id_to_tasks.sql b/play-life-backend/migrations_old/021_add_wishlist_id_to_tasks.sql similarity index 100% rename from play-life-backend/migrations/021_add_wishlist_id_to_tasks.sql rename to play-life-backend/migrations_old/021_add_wishlist_id_to_tasks.sql diff --git a/play-life-backend/migrations/022_refactor_configs_to_tasks.sql b/play-life-backend/migrations_old/022_refactor_configs_to_tasks.sql similarity index 100% rename from play-life-backend/migrations/022_refactor_configs_to_tasks.sql rename to play-life-backend/migrations_old/022_refactor_configs_to_tasks.sql diff --git a/play-life-backend/migrations/023_add_wishlist_boards.sql b/play-life-backend/migrations_old/023_add_wishlist_boards.sql similarity index 100% rename from play-life-backend/migrations/023_add_wishlist_boards.sql rename to play-life-backend/migrations_old/023_add_wishlist_boards.sql diff --git a/play-life-backend/migrations/024_add_reward_policy.sql b/play-life-backend/migrations_old/024_add_reward_policy.sql similarity index 100% rename from play-life-backend/migrations/024_add_reward_policy.sql rename to play-life-backend/migrations_old/024_add_reward_policy.sql diff --git a/play-life-backend/migrations/025_remove_conditions_without_user_id.sql b/play-life-backend/migrations_old/025_remove_conditions_without_user_id.sql similarity index 100% rename from play-life-backend/migrations/025_remove_conditions_without_user_id.sql rename to play-life-backend/migrations_old/025_remove_conditions_without_user_id.sql diff --git a/play-life-backend/migrations/026_weekly_goals_max_score.sql b/play-life-backend/migrations_old/026_weekly_goals_max_score.sql similarity index 100% rename from play-life-backend/migrations/026_weekly_goals_max_score.sql rename to play-life-backend/migrations_old/026_weekly_goals_max_score.sql diff --git a/play-life-backend/migrations/027_add_normalized_total_score_to_weekly_report_mv.sql b/play-life-backend/migrations_old/027_add_normalized_total_score_to_weekly_report_mv.sql similarity index 100% rename from play-life-backend/migrations/027_add_normalized_total_score_to_weekly_report_mv.sql rename to play-life-backend/migrations_old/027_add_normalized_total_score_to_weekly_report_mv.sql diff --git a/play-life-backend/migrations/028_optimize_task_queries.sql b/play-life-backend/migrations_old/028_optimize_task_queries.sql similarity index 100% rename from play-life-backend/migrations/028_optimize_task_queries.sql rename to play-life-backend/migrations_old/028_optimize_task_queries.sql diff --git a/play-life-backend/migrations/029_add_covering_indexes.sql b/play-life-backend/migrations_old/029_add_covering_indexes.sql similarity index 100% rename from play-life-backend/migrations/029_add_covering_indexes.sql rename to play-life-backend/migrations_old/029_add_covering_indexes.sql diff --git a/play-life-backend/migrations_old/README.md b/play-life-backend/migrations_old/README.md new file mode 100644 index 0000000..d9d7f11 --- /dev/null +++ b/play-life-backend/migrations_old/README.md @@ -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 ` для создания новых миграций. diff --git a/play-life-backend/test_baseline.sh b/play-life-backend/test_baseline.sh new file mode 100755 index 0000000..ffdb285 --- /dev/null +++ b/play-life-backend/test_baseline.sh @@ -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 "" diff --git a/play-life-backend/validate_baseline.sh b/play-life-backend/validate_baseline.sh new file mode 100755 index 0000000..dad376f --- /dev/null +++ b/play-life-backend/validate_baseline.sh @@ -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" diff --git a/play-life-web/package.json b/play-life-web/package.json index 0d9f9f8..498ad7b 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "3.28.4", + "version": "4.0.0", "type": "module", "scripts": { "dev": "vite",