mnemo_cards/mnemo_cards_backend/DATABASE_IMPROVEMENT_PLAN_DETAILED.md
Dmitry 4ed1839893
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
format and logs
2026-01-08 16:02:47 +03:00

30 KiB
Raw Blame History

📋 Детальный план улучшений базы данных

Дата: 14 декабря 2025
БД будет пересоздана с нуля - SQL миграции не нужны


🎯 Итоговые решения

Что делаем:

  1. WordStatistics - вместо SessionCards (агрегация в реальном времени)
  2. AchievementDefinitions + нормализация UserAchievements
  3. Удаление 5 JSON полей из UserDatas
  4. Удаление deprecated полей из Payments
  5. Удаление packId из GameCards
  6. Добавление метаданных в CardPacks
  7. Исправление UserSubscriptions
  8. Добавление soft delete везде
  9. Создание AuditLog

Что НЕ делаем (отложено):

  • SessionCards (слишком много записей)
  • Отзывы на паки
  • A/B тесты
  • User-Generated Content
  • Новые индексы (потом по мониторингу)

📝 Детальные шаги реализации

Этап 1: Создание новых таблиц (Drift schemas)

Шаг 1.1: Создать таблицу WordStatistics

Файл: lib/database/tables/word_statistics.dart

import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';
import 'users.dart';
import 'packs.dart';

/// Статистика изучения слов (агрегируется в реальном времени)
/// Вместо миллионов SessionCards - одна запись на user×card
class WordStatistics extends Table {
  TextColumn get id => text()
      .withDefault(const CustomExpression('gen_random_uuid()::text'))();
  TextColumn get userId => text()
      .references(Users, #id, onDelete: KeyAction.cascade)();
  TextColumn get cardId => text()
      .references(GameCards, #id, onDelete: KeyAction.cascade)();
  
  // === Агрегированная статистика ===
  IntColumn get totalReviews => integer().withDefault(const Constant(0))();
  IntColumn get correctAnswers => integer().withDefault(const Constant(0))();
  IntColumn get incorrectAnswers => integer().withDefault(const Constant(0))();
  
  // Мастерство (correctAnswers / totalReviews)
  RealColumn get mastery => real().withDefault(const Constant(0.0))();
  
  // Текущая и максимальная серия правильных ответов подряд
  IntColumn get currentStreak => integer().withDefault(const Constant(0))();
  IntColumn get longestStreak => integer().withDefault(const Constant(0))();
  
  // === Spaced Repetition (SM-2 алгоритм) ===
  Column<PgDateTime> get lastReviewed => customType(PgTypes.timestampWithTimezone)
      .nullable()();
  Column<PgDateTime> get nextReview => customType(PgTypes.timestampWithTimezone)
      .nullable()();
  
  // SM-2 параметры
  IntColumn get repetitions => integer().withDefault(const Constant(0))();
  RealColumn get easinessFactor => real().withDefault(const Constant(2.5))();
  IntColumn get intervalDays => integer().withDefault(const Constant(1))();
  
  // === Последняя попытка (для UI) ===
  IntColumn get lastAttempts => integer().withDefault(const Constant(1))();
  IntColumn get lastTimeSpentMs => integer().withDefault(const Constant(0))();
  BoolColumn get lastWasCorrect => boolean().nullable()();
  
  // === Audit ===
  Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
      .withDefault(now())();
  Column<PgDateTime> get updatedAt => customType(PgTypes.timestampWithTimezone)
      .withDefault(now())();
  BoolColumn get isDeleted => boolean()
      .withDefault(const Constant(false))
      .customConstraint('')();
  Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
      .nullable()();
  
  @override
  Set<Column> get primaryKey => {id};
  
  @override
  List<String> get customConstraints => [
    'UNIQUE(user_id, card_id)',  // Одна статистика на пользователя×карточку
  ];
}

Шаг 1.2: Создать таблицу AchievementDefinitions

Файл: Обновить lib/database/tables/achievements.dart

import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';
import 'users.dart';

/// Справочник всех возможных достижений в системе
class AchievementDefinitions extends Table {
  TextColumn get id => text()
      .withDefault(const CustomExpression('gen_random_uuid()::text'))();
  
  // === Идентификация ===
  TextColumn get code => text().unique()();  // FIRST_PACK, STREAK_7, TESTS_100
  
  // === UI информация ===
  TextColumn get title => text()();
  TextColumn get description => text()();
  TextColumn get iconUrl => text().nullable()();
  
  // === Условия получения (JSON) ===
  // Примеры:
  // {"type": "purchase_pack", "count": 1}
  // {"type": "streak", "days": 7}
  // {"type": "complete_tests", "count": 100, "min_score": 90}
  TextColumn get requirement => text()();
  
  // === Награды (JSON, опционально) ===
  // {"coins": 100, "packs": ["pack-id"], "premium_days": 7}
  TextColumn get rewards => text().nullable()();
  
  // === Геймификация ===
  IntColumn get points => integer().withDefault(const Constant(0))();
  TextColumn get rarity => text().withDefault(const Constant('common'))();  // common, rare, epic, legendary
  IntColumn get sortOrder => integer().withDefault(const Constant(0))();
  
  // === Категория (для фильтрации) ===
  TextColumn get category => text().nullable()();  // learning, social, streak, purchase
  
  // === Audit ===
  Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
      .withDefault(now())();
  Column<PgDateTime> get updatedAt => customType(PgTypes.timestampWithTimezone)
      .withDefault(now())();
  BoolColumn get isActive => boolean()
      .withDefault(const Constant(true))
      .customConstraint('')();
  BoolColumn get isDeleted => boolean()
      .withDefault(const Constant(false))
      .customConstraint('')();
  
  @override
  Set<Column> get primaryKey => {id};
}

/// Таблица UserAchievements - достижения пользователей (ОБНОВЛЕННАЯ)
class UserAchievements extends Table {
  TextColumn get id => text()
      .withDefault(const CustomExpression('gen_random_uuid()::text'))();
  TextColumn get userId => text()
      .references(Users, #id, onDelete: KeyAction.cascade)();
  TextColumn get achievementId => text()
      .references(AchievementDefinitions, #id, onDelete: KeyAction.cascade)();
  
  // === Когда получено ===
  Column<PgDateTime> get unlockedAt => customType(PgTypes.timestampWithTimezone)
      .withDefault(now())();
  
  // === Прогресс (если достижение имеет промежуточные этапы) ===
  IntColumn get progress => integer().withDefault(const Constant(0))();
  IntColumn get progressMax => integer().withDefault(const Constant(100))();
  
  @override
  Set<Column> get primaryKey => {id};
  
  @override
  List<String> get customConstraints => [
    'UNIQUE(user_id, achievement_id)',  // Каждое достижение получается один раз
  ];
}

Шаг 1.3: Обновить таблицу UserDatas

Файл: lib/database/tables/users.dart

УДАЛИТЬ эти поля:

// ❌ УДАЛИТЬ:
TextColumn get words => text()...
TextColumn get achievements => text()...
TextColumn get packProgress => text()...
TextColumn get studyDates => text()...
TextColumn get categoryMinutes => text()...

Итоговая UserDatas:

class UserDatas extends Table {
  TextColumn get id => text()
      .withDefault(const CustomExpression('gen_random_uuid()::text'))();
  TextColumn get userId => text()
      .unique()
      .references(Users, #id, onDelete: KeyAction.cascade)();
  
  // === Простые счетчики (оставляем) ===
  IntColumn get totalStudyTimeMinutes => integer().withDefault(const Constant(0))();
  IntColumn get currentStreak => integer().withDefault(const Constant(0))();
  IntColumn get longestStreak => integer().withDefault(const Constant(0))();
  IntColumn get totalCards => integer().withDefault(const Constant(0))();
  IntColumn get totalTests => integer().withDefault(const Constant(0))();
  
  // === Временные метки ===
  Column<PgDateTime> get lastTimeOnline => customType(PgTypes.timestampWithTimezone)
      .nullable()();
  Column<PgDateTime> get registrationDate => customType(PgTypes.timestampWithTimezone)
      .withDefault(now())();
  TextColumn get lastTestSessionToken => text().nullable()();
  
  // === Небольшой JSON массив (оставляем) ===
  TextColumn get tags => text()
      .withDefault(const Constant('[]'))
      .map(const StringListConverter())();
  
  // === Audit ===
  Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
      .withDefault(now())();
  Column<PgDateTime> get updatedAt => customType(PgTypes.timestampWithTimezone)
      .withDefault(now())();
  
  @override
  Set<Column> get primaryKey => {id};
}

Шаг 1.4: Обновить таблицу Payments

Файл: lib/database/tables/payments.dart

УДАЛИТЬ deprecated поля:

// ❌ УДАЛИТЬ:
TextColumn get packs => text()...
BoolColumn get subscription => boolean()...

Добавить soft delete:

// ✅ ДОБАВИТЬ:
BoolColumn get isDeleted => boolean()
    .withDefault(const Constant(false))
    .customConstraint('')();
Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
    .nullable()();

Шаг 1.5: Обновить таблицу GameCards

Файл: lib/database/tables/packs.dart

УДАЛИТЬ поле packId:

// ❌ УДАЛИТЬ из GameCards:
TextColumn get packId => text()
    .references(CardPacks, #id, onDelete: KeyAction.cascade)();

Теперь связь Pack ↔ Card только через CardPackCards!


Шаг 1.6: Обновить таблицу CardPacks

Файл: lib/database/tables/packs.dart

ДОБАВИТЬ метаданные:

class CardPacks extends Table {
  // ... существующие поля ...
  
  // === ДОБАВИТЬ новые поля ===
  
  // Категория и язык
  TextColumn get category => text().nullable()();  // "Еда", "Путешествия", "Бизнес"
  TextColumn get language => text().withDefault(const Constant('en'))();  // en, es, fr, de
  TextColumn get difficulty => text().nullable()();  // beginner, intermediate, advanced
  
  // Метаинформация
  IntColumn get estimatedMinutes => integer().nullable()();  // Время на прохождение
  TextColumn get authorId => text().nullable()();  // Для UGC в будущем
  
  // Метрики популярности
  IntColumn get purchaseCount => integer().withDefault(const Constant(0))();
  IntColumn get viewCount => integer().withDefault(const Constant(0))();
  RealColumn get avgRating => real().nullable()();  // Для отзывов в будущем
  
  // ... остальные поля как есть ...
}

Шаг 1.7: Обновить таблицу UserSubscriptions

Файл: lib/database/tables/subscriptions.dart

Изменения:

class UserSubscriptions extends Table {
  TextColumn get id => text()
      .withDefault(const CustomExpression('gen_random_uuid()::text'))();
  
  // ❌ УБРАТЬ unique() - пользователь может иметь историю подписок
  TextColumn get userId => text()
      .references(Users, #id, onDelete: KeyAction.cascade)();
  
  // ✅ ДОБАВИТЬ связь с планом
  TextColumn get planId => text()
      .nullable()
      .references(SubscriptionPlans, #id)();
  
  Column<PgDateTime> get start => customType(PgTypes.timestampWithTimezone)();
  Column<PgDateTime> get finish => customType(PgTypes.timestampWithTimezone)();
  
  // ✅ ДОБАВИТЬ статус
  TextColumn get status => text()
      .withDefault(const Constant('active'))();  // active, expired, cancelled, paused
  
  // ✅ ДОБАВИТЬ информацию о подписке
  BoolColumn get autoRenew => boolean().withDefault(const Constant(false))();
  TextColumn get paymentId => text().nullable()();  // Связь с Payments
  Column<PgDateTime> get cancelledAt => customType(PgTypes.timestampWithTimezone)
      .nullable()();
  TextColumn get cancellationReason => text().nullable()();
  
  // Функции подписки (JSON array) - оставляем
  TextColumn get features => text()
      .withDefault(const Constant('[]'))
      .map(const JsonListConverter())();
  
  // Audit
  Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
      .withDefault(now())();
  Column<PgDateTime> get updatedAt => customType(PgTypes.timestampWithTimezone)
      .withDefault(now())();
  
  @override
  Set<Column> get primaryKey => {id};
}

Шаг 1.8: Добавить soft delete во все таблицы

Затронутые файлы:

  • lib/database/tables/auth.dart (Tokens, RefreshTokens, TelegramAuthCodes)
  • lib/database/tables/statistics.dart (StudySessions)
  • lib/database/tables/tests.dart (Tests, TestQuestions)
  • lib/database/tables/promo_codes.dart (PromoCodesCampaigns, PromoCodes)
  • lib/database/tables/discounts.dart (DiscountCampaigns, Discounts)
  • lib/database/tables/tasks.dart (Tasks, UserTasks)

Добавить в каждую таблицу:

BoolColumn get isDeleted => boolean()
    .withDefault(const Constant(false))
    .customConstraint('')();

Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
    .nullable()();

Шаг 1.9: Создать таблицу AuditLog

Файл: Новый lib/database/tables/audit.dart

import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';

/// Таблица AuditLog - журнал всех изменений критичных данных
class AuditLogs extends Table {
  TextColumn get id => text()
      .withDefault(const CustomExpression('gen_random_uuid()::text'))();
  
  // === Что изменено ===
  TextColumn get tableName => text()();
  TextColumn get recordId => text()();
  TextColumn get action => text()();  // INSERT, UPDATE, DELETE
  
  // === Кто изменил ===
  TextColumn get userId => text().nullable()();
  
  // === Данные до и после (JSON as TEXT) ===
  TextColumn get oldData => text().nullable()();
  TextColumn get newData => text().nullable()();
  
  // === Дополнительная информация ===
  TextColumn get ipAddress => text().nullable()();
  TextColumn get userAgent => text().nullable()();
  
  // === Когда ===
  Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
      .withDefault(now())();
  
  @override
  Set<Column> get primaryKey => {id};
}

Этап 2: Обновить database.dart

Файл: lib/database/database.dart

Добавить импорты:

import 'tables/word_statistics.dart';
import 'tables/audit.dart';
// achievements.dart уже импортирован, но обновлен

import 'daos/word_statistics_dao.dart';
import 'daos/audit_dao.dart';

Обновить @DriftDatabase:

@DriftDatabase(
  tables: [
    // ... существующие таблицы ...
    
    // ✅ ДОБАВИТЬ новые:
    WordStatistics,
    AuditLogs,
    AchievementDefinitions,
    // UserAchievements уже есть, но обновлена
  ],
  daos: [
    // ... существующие DAO ...
    
    // ✅ ДОБАВИТЬ новые:
    WordStatisticsDao,
    AuditDao,
  ],
)
class AppDatabase extends _$AppDatabase {
  // ...
  
  @override
  int get schemaVersion => 2;  // ✅ Увеличить версию схемы
}

Этап 3: Создание DAO

Шаг 3.1: Создать WordStatisticsDao

Файл: lib/database/daos/word_statistics_dao.dart

import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';
import '../database.dart';
import '../tables/word_statistics.dart';
import 'dart:math' as math;

part 'word_statistics_dao.g.dart';

@DriftAccessor(tables: [WordStatistics])
class WordStatisticsDao extends DatabaseAccessor<AppDatabase> 
    with _$WordStatisticsDaoMixin {
  WordStatisticsDao(super.db);
  
  /// Записать результат повторения карточки (основной метод)
  /// Обновляет статистику и вычисляет nextReview по SM-2 алгоритму
  Future<void> recordReview({
    required String userId,
    required String cardId,
    required bool wasCorrect,
    int attempts = 1,
    int timeSpentMs = 0,
  }) async {
    await transaction(() async {
      // Получить или создать статистику
      var stats = await getOrCreate(userId: userId, cardId: cardId);
      
      // Обновить счетчики
      final totalReviews = stats.totalReviews + 1;
      final correctAnswers = stats.correctAnswers + (wasCorrect ? 1 : 0);
      final incorrectAnswers = stats.incorrectAnswers + (wasCorrect ? 0 : 1);
      final mastery = correctAnswers / totalReviews;
      
      // Обновить streak
      final currentStreak = wasCorrect ? stats.currentStreak + 1 : 0;
      final longestStreak = math.max(stats.longestStreak, currentStreak);
      
      // Вычислить nextReview по SM-2 алгоритму
      final sm2Result = _calculateSM2(
        quality: wasCorrect ? (attempts == 1 ? 5 : 4) : 2,
        easinessFactor: stats.easinessFactor,
        interval: stats.intervalDays,
        repetitions: stats.repetitions,
      );
      
      // Обновить запись
      await (update(wordStatistics)..where((w) => w.id.equals(stats.id)))
          .write(WordStatisticsCompanion(
            totalReviews: Value(totalReviews),
            correctAnswers: Value(correctAnswers),
            incorrectAnswers: Value(incorrectAnswers),
            mastery: Value(mastery),
            currentStreak: Value(currentStreak),
            longestStreak: Value(longestStreak),
            
            lastReviewed: Value(PgDateTime(DateTime.now())),
            nextReview: Value(PgDateTime(sm2Result.nextReview)),
            repetitions: Value(sm2Result.repetitions),
            easinessFactor: Value(sm2Result.easinessFactor),
            intervalDays: Value(sm2Result.interval),
            
            lastAttempts: Value(attempts),
            lastTimeSpentMs: Value(timeSpentMs),
            lastWasCorrect: Value(wasCorrect),
            
            updatedAt: Value(PgDateTime(DateTime.now())),
          ));
    });
  }
  
  /// Получить статистику по карточке (или создать если нет)
  Future<WordStatistic> getOrCreate({
    required String userId,
    required String cardId,
  }) async {
    var existing = await (select(wordStatistics)
      ..where((w) => w.userId.equals(userId) & w.cardId.equals(cardId))
    ).getSingleOrNull();
    
    if (existing != null) return existing;
    
    // Создать новую запись
    await into(wordStatistics).insert(
      WordStatisticsCompanion.insert(
        userId: userId,
        cardId: cardId,
      ),
    );
    
    return await (select(wordStatistics)
      ..where((w) => w.userId.equals(userId) & w.cardId.equals(cardId))
    ).getSingle();
  }
  
  /// Получить все слова пользователя
  Future<List<WordStatistic>> getUserWordStats(String userId) {
    return (select(wordStatistics)
      ..where((w) => w.userId.equals(userId) & w.isDeleted.equals(false))
      ..orderBy([(w) => OrderingTerm.desc(w.mastery)])
    ).get();
  }
  
  /// Получить слова, которые пора повторить
  Future<List<WordStatistic>> getWordsForReview(String userId) {
    final now = PgDateTime(DateTime.now());
    return (select(wordStatistics)
      ..where((w) => 
        w.userId.equals(userId) & 
        w.isDeleted.equals(false) &
        w.nextReview.isSmallerOrEqualValue(now)
      )
      ..orderBy([(w) => OrderingTerm.asc(w.nextReview)])
      ..limit(20)
    ).get();
  }
  
  /// SM-2 алгоритм (Spaced Repetition)
  _SM2Result _calculateSM2({
    required int quality,  // 0-5 (5 = perfect, 0 = complete blackout)
    required double easinessFactor,
    required int interval,
    required int repetitions,
  }) {
    var ef = easinessFactor;
    var reps = repetitions;
    var inter = interval;
    
    // Обновить easiness factor
    ef = ef + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
    if (ef < 1.3) ef = 1.3;
    
    // Если ответ был плохим (quality < 3), сбросить
    if (quality < 3) {
      reps = 0;
      inter = 1;
    } else {
      reps += 1;
      if (reps == 1) {
        inter = 1;
      } else if (reps == 2) {
        inter = 6;
      } else {
        inter = (inter * ef).round();
      }
    }
    
    final nextReview = DateTime.now().add(Duration(days: inter));
    
    return _SM2Result(
      easinessFactor: ef,
      interval: inter,
      repetitions: reps,
      nextReview: nextReview,
    );
  }
}

class _SM2Result {
  final double easinessFactor;
  final int interval;
  final int repetitions;
  final DateTime nextReview;
  
  _SM2Result({
    required this.easinessFactor,
    required this.interval,
    required this.repetitions,
    required this.nextReview,
  });
}

Шаг 3.2: Создать AuditDao

Файл: lib/database/daos/audit_dao.dart

import 'package:drift/drift.dart';
import 'package:drift_postgres/drift_postgres.dart';
import 'dart:convert';
import '../database.dart';
import '../tables/audit.dart';

part 'audit_dao.g.dart';

@DriftAccessor(tables: [AuditLogs])
class AuditDao extends DatabaseAccessor<AppDatabase> with _$AuditDaoMixin {
  AuditDao(super.db);
  
  /// Записать изменение в audit log
  Future<void> log({
    required String tableName,
    required String recordId,
    required String action,  // INSERT, UPDATE, DELETE
    String? userId,
    Map<String, dynamic>? oldData,
    Map<String, dynamic>? newData,
    String? ipAddress,
    String? userAgent,
  }) async {
    await into(auditLogs).insert(
      AuditLogsCompanion.insert(
        tableName: tableName,
        recordId: recordId,
        action: action,
        userId: Value(userId),
        oldData: Value(oldData != null ? jsonEncode(oldData) : null),
        newData: Value(newData != null ? jsonEncode(newData) : null),
        ipAddress: Value(ipAddress),
        userAgent: Value(userAgent),
      ),
    );
  }
  
  /// Получить историю изменений записи
  Future<List<AuditLog>> getLogsByRecord({
    required String tableName,
    required String recordId,
    int? limit,
  }) {
    final query = select(auditLogs)
      ..where((a) => 
        a.tableName.equals(tableName) & 
        a.recordId.equals(recordId)
      )
      ..orderBy([(a) => OrderingTerm.desc(a.createdAt)]);
    
    if (limit != null) {
      query.limit(limit);
    }
    
    return query.get();
  }
  
  /// Получить все действия пользователя
  Future<List<AuditLog>> getUserActions(String userId, {int? limit}) {
    final query = select(auditLogs)
      ..where((a) => a.userId.equals(userId))
      ..orderBy([(a) => OrderingTerm.desc(a.createdAt)]);
    
    if (limit != null) {
      query.limit(limit);
    }
    
    return query.get();
  }
}

Шаг 3.3: Обновить существующие DAO (добавить soft delete)

Добавить в каждый DAO метод для soft delete:

// Пример для UserDao, PackDao, TestDao, etc
Future<void> softDelete(String id) async {
  await (update(tableName)..where((t) => t.id.equals(id)))
      .write(TableCompanion(
        isDeleted: const Value(true),
        deletedAt: Value(PgDateTime(DateTime.now())),
        updatedAt: Value(PgDateTime(DateTime.now())),
      ));
}

// Обновить методы выборки - фильтровать isDeleted
Future<List<TableData>> getAll({bool includeDeleted = false}) {
  final query = select(tableName);
  if (!includeDeleted) {
    query.where((t) => t.isDeleted.equals(false));
  }
  return query.get();
}

Этап 4: Регенерация кода

cd mnemo_cards_backend
dart run build_runner build --delete-conflicting-outputs

Этап 5: Обновление бизнес-логики

Шаг 5.1: Обновить UserManager

Файл: lib/user/user_manager.dart

Изменения:

  • Убрать обращения к userData.words, userData.achievements, etc
  • Добавить методы расчета packProgress, studyDates, categoryMinutes на лету
/// Получить прогресс по пакам (рассчитывается на лету)
Future<List<PackProgress>> getPackProgress(String userId) async {
  final userPacks = await db.userDao.getUserPacks(userId);
  final result = <PackProgress>[];
  
  for (final pack in userPacks) {
    // Получить все карточки пака
    final cards = await db.packDao.getPackCards(pack.id);
    
    // Получить статистику по карточкам
    final stats = await db.wordStatisticsDao.getUserWordStats(userId);
    final packStats = stats.where((s) => 
      cards.any((c) => c.id == s.cardId)
    ).toList();
    
    // Рассчитать прогресс
    final learnedCount = packStats.where((s) => s.mastery > 0.7).length;
    final progress = cards.isEmpty ? 0.0 : learnedCount / cards.length;
    
    result.add(PackProgress(
      packId: pack.id,
      totalCards: cards.length,
      learnedCards: learnedCount,
      progress: progress,
    ));
  }
  
  return result;
}

/// Получить даты обучения (рассчитывается из StudySessions)
Future<List<DateTime>> getStudyDates(String userId) async {
  final sessions = await db.statisticsDao.getUserSessions(userId);
  return sessions.map((s) => s.startTime.dateTime).toSet().toList()
    ..sort((a, b) => b.compareTo(a));
}

/// Получить минуты по категориям (рассчитывается из StudySessions + Pack.category)
Future<Map<String, int>> getCategoryMinutes(String userId) async {
  final sessions = await db.statisticsDao.getUserSessions(userId);
  final result = <String, int>{};
  
  for (final session in sessions) {
    if (session.packId == null) continue;
    
    final pack = await db.packDao.getPackById(session.packId!);
    if (pack == null) continue;
    
    final category = pack.category ?? 'uncategorized';
    final minutes = session.endTime != null
        ? session.endTime!.dateTime.difference(session.startTime.dateTime).inMinutes
        : 0;
    
    result[category] = (result[category] ?? 0) + minutes;
  }
  
  return result;
}

Шаг 5.2: Интеграция AuditLog

Добавить логирование критичных операций:

// В PaymentManager при создании платежа
await db.auditDao.log(
  tableName: 'payments',
  recordId: payment.id,
  action: 'INSERT',
  userId: userId,
  newData: payment.toJson(),
  ipAddress: request.headers['x-forwarded-for'],
  userAgent: request.headers['user-agent'],
);

// В SubscriptionManager при отмене подписки
await db.auditDao.log(
  tableName: 'user_subscriptions',
  recordId: subscriptionId,
  action: 'UPDATE',
  userId: userId,
  oldData: {'status': 'active'},
  newData: {'status': 'cancelled'},
);

Шаг 5.3: Обновить API endpoints

Обновить все API, которые используют:

  • userData.words → использовать WordStatisticsDao
  • userData.achievements → использовать UserAchievements + AchievementDefinitions
  • card.packId → использовать CardPackCards

Этап 6: Тестирование

Шаг 6.1: Unit тесты для новых DAO

Файл: test/database/word_statistics_dao_test.dart

import 'package:test/test.dart';
import 'package:mnemo_cards_backend/database/database.dart';

void main() {
  late AppDatabase db;
  
  setUp(() async {
    db = AppDatabase.connect(/* test credentials */);
    await db.migrator.createAll();
  });
  
  tearDown(() async {
    await db.close();
  });
  
  test('recordReview создает и обновляет статистику', () async {
    const userId = 'user-1';
    const cardId = 'card-1';
    
    // Первое повторение
    await db.wordStatisticsDao.recordReview(
      userId: userId,
      cardId: cardId,
      wasCorrect: true,
    );
    
    var stats = await db.wordStatisticsDao.getOrCreate(
      userId: userId,
      cardId: cardId,
    );
    
    expect(stats.totalReviews, equals(1));
    expect(stats.correctAnswers, equals(1));
    expect(stats.mastery, equals(1.0));
    
    // Второе повторение
    await db.wordStatisticsDao.recordReview(
      userId: userId,
      cardId: cardId,
      wasCorrect: false,
    );
    
    stats = await db.wordStatisticsDao.getOrCreate(
      userId: userId,
      cardId: cardId,
    );
    
    expect(stats.totalReviews, equals(2));
    expect(stats.correctAnswers, equals(1));
    expect(stats.incorrectAnswers, equals(1));
    expect(stats.mastery, equals(0.5));
  });
}

Проверка успешности

  • dart run build_runner build проходит без ошибок
  • Все unit тесты проходят
  • Backend запускается
  • БД создается с правильной схемой
  • API endpoints работают
  • WordStatistics записывает данные при изучении
  • Spaced Repetition работает (nextReview вычисляется)
  • packProgress, studyDates, categoryMinutes рассчитываются корректно
  • AuditLog записывает критичные операции
  • Soft delete работает для всех таблиц
  • CardPacks имеет новые поля (category, language, etc)
  • UserSubscriptions имеет историю (не unique userId)

Готовы начать реализацию?