diff --git a/mnemo_cards_admin/src/pages/CardsPage.tsx b/mnemo_cards_admin/src/pages/CardsPage.tsx index 8dcf555..480deaa 100644 --- a/mnemo_cards_admin/src/pages/CardsPage.tsx +++ b/mnemo_cards_admin/src/pages/CardsPage.tsx @@ -226,7 +226,7 @@ export default function CardsPage() { } if (error) { - const errorMessage = error instanceof CardsApiError + const errorMessage = isCardsApiError(error) ? error.message : formatApiError(error) return ( diff --git a/mnemo_cards_admin/src/pages/PacksPage.tsx b/mnemo_cards_admin/src/pages/PacksPage.tsx index d55844f..e7c2ea6 100644 --- a/mnemo_cards_admin/src/pages/PacksPage.tsx +++ b/mnemo_cards_admin/src/pages/PacksPage.tsx @@ -259,7 +259,7 @@ export default function PacksPage() { } if (error) { - const errorMessage = error instanceof PacksApiError + const errorMessage = isPacksApiError(error) ? error.message : formatApiError(error) return ( diff --git a/mnemo_cards_backend/lib/api/di/injector.config.dart b/mnemo_cards_backend/lib/api/di/injector.config.dart index 3d54341..96c1587 100644 --- a/mnemo_cards_backend/lib/api/di/injector.config.dart +++ b/mnemo_cards_backend/lib/api/di/injector.config.dart @@ -73,9 +73,6 @@ extension GetItInjectableX on _i174.GetIt { gh.lazySingleton<_i240.TelegramAuthCodeService>( () => _i240.TelegramAuthCodeService(), ); - gh.lazySingleton<_i1029.StatisticsCalculator>( - () => _i1029.StatisticsCalculator(), - ); gh.lazySingleton<_i377.SubscriptionManager>( () => _i377.SubscriptionManager(gh<_i1072.AppDatabase>()), ); @@ -97,6 +94,9 @@ extension GetItInjectableX on _i174.GetIt { gh.lazySingleton<_i71.SessionTracker>( () => _i71.SessionTracker(gh<_i1072.AppDatabase>()), ); + gh.lazySingleton<_i1029.StatisticsCalculator>( + () => _i1029.StatisticsCalculator(gh<_i1072.AppDatabase>()), + ); gh.lazySingleton<_i909.WordStatisticsManager>( () => _i909.WordStatisticsManager(gh<_i1072.AppDatabase>()), ); @@ -125,15 +125,6 @@ extension GetItInjectableX on _i174.GetIt { gh<_i222.RustorePurchaseHandler>(), ), ); - gh.lazySingleton<_i985.TasksApiV2>( - () => _i985.TasksApiV2(gh<_i586.TaskManager>()), - ); - gh.lazySingleton<_i151.PromoCodesManager>( - () => _i151.PromoCodesManager( - gh<_i1072.AppDatabase>(), - gh<_i1009.PaymentManager>(), - ), - ); gh.lazySingleton<_i280.UserManager>( () => _i280.UserManager( gh<_i1072.AppDatabase>(), @@ -141,6 +132,23 @@ extension GetItInjectableX on _i174.GetIt { gh<_i71.SessionTracker>(), gh<_i1029.StatisticsCalculator>(), gh<_i802.AchievementManager>(), + gh<_i909.WordStatisticsManager>(), + ), + ); + gh.lazySingleton<_i985.TasksApiV2>( + () => _i985.TasksApiV2(gh<_i586.TaskManager>()), + ); + gh.lazySingleton<_i483.AdminAuthApiV2>( + () => _i483.AdminAuthApiV2( + gh<_i240.TelegramAuthCodeService>(), + gh<_i280.UserManager>(), + gh<_i108.JwtService>(), + ), + ); + gh.lazySingleton<_i151.PromoCodesManager>( + () => _i151.PromoCodesManager( + gh<_i1072.AppDatabase>(), + gh<_i1009.PaymentManager>(), ), ); gh.lazySingleton<_i560.UserManager>( @@ -194,13 +202,6 @@ extension GetItInjectableX on _i174.GetIt { gh<_i240.TelegramAuthCodeService>(), ), ); - gh.lazySingleton<_i483.AdminAuthApiV2>( - () => _i483.AdminAuthApiV2( - gh<_i240.TelegramAuthCodeService>(), - gh<_i280.UserManager>(), - gh<_i108.JwtService>(), - ), - ); gh.lazySingleton<_i433.PackDtoConverter>( () => _i433.PackDtoConverter( gh<_i908.ProductsPriceResolver>(), diff --git a/mnemo_cards_backend/lib/api/v2/users_api_v2.dart b/mnemo_cards_backend/lib/api/v2/users_api_v2.dart index db89827..ef7253c 100644 --- a/mnemo_cards_backend/lib/api/v2/users_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/users_api_v2.dart @@ -56,6 +56,9 @@ class UsersApiV2 { /// GET /api/v2/users/me /// Returns current authenticated user. + /// + /// Рассчитывает packProgress, studyDates, categoryMinutes на лету + /// из WordStatistics и StudySessions (вместо удаленных полей UserDatas) @Route.get('/users/me') Future getCurrentUser(Request request) async { final user = request.user; @@ -63,7 +66,37 @@ class UsersApiV2 { return _unauthorized(); } - final dto = await user.toDto(); + if (user.id == null) { + return _json({'error': 'user_id_not_found'}, statusCode: 400); + } + + // Рассчитать статистику на лету + final packProgress = await _statisticsCalculator.calculateAllPackProgress(user.id!); + final studyDates = await _statisticsCalculator.calculateStudyDates(user.id!); + final categoryMinutes = await _statisticsCalculator.calculateCategoryMinutes(user.id!); + + // Получить статистику по словам из WordStatistics + final wordStats = await _db.wordStatisticsDao.getUserStatistics(user.id!); + final wordsDto = AllWordsStatisticsDto( + words: wordStats.map((stat) { + // Найти карточку для получения слова + // Для простоты используем cardId как word (в будущем можно улучшить) + return WordStatisticsDto( + word: stat.cardId, // Временное решение - нужно получить original из GameCard + correct: stat.correctAnswers.toDouble(), + incorrect: stat.incorrectAnswers.toDouble(), + skipped: 0, + questionTypes: {}, + ); + }).toList(), + ); + + final dto = await user.toDtoWithCalculatedData( + packProgress: packProgress, + studyDates: studyDates, + categoryMinutes: categoryMinutes, + wordsStatistics: wordsDto, + ); return _json(dto.toJson()); } @@ -245,14 +278,14 @@ class UsersApiV2 { return _json({'error': 'user_data_not_found'}, statusCode: 404); } - // Use UserDataModel extension to convert to DTO - // TODO: Need to create proper conversion from Drift UserData to UserDataDto - // For now, return basic structure + // Рассчитать studyDates на лету из StudySessions + final studyDates = await _statisticsCalculator.calculateStudyDates(user.id!); + return _json({ 'totalStudyTimeMinutes': userData.totalStudyTimeMinutes, 'currentStreak': userData.currentStreak, 'longestStreak': userData.longestStreak, - 'studyDates': (userData.studyDates ?? []).map((d) => d.toIso8601String()).toList(), + 'studyDates': studyDates.map((d) => d.toIso8601String()).toList(), }); } catch (e) { return _json( @@ -280,11 +313,14 @@ class UsersApiV2 { return _json([]); } + // Рассчитать packProgress на лету из WordStatistics + final allPackProgress = await _statisticsCalculator.calculateAllPackProgress(user.id!); + final packProgress = packId != null - ? userData.packProgress.where((p) => p.packId == packId).toList() - : userData.packProgress; + ? allPackProgress.where((p) => p.packId == packId).toList() + : allPackProgress; - final dtos = packProgress.map((p) => p.toDto()).toList(); + final dtos = packProgress; return _json(dtos.map((dto) => dto.toJson()).toList()); } catch (e) { return _json( @@ -444,8 +480,9 @@ class UsersApiV2 { }); } - final timelineData = _statisticsCalculator.getTimelineStatistics( - userData, + // Рассчитать timeline statistics (теперь принимает userId вместо UserDataModel) + final timelineData = await _statisticsCalculator.getTimelineStatistics( + user.id!, period: period, from: fromDate, to: toDate, @@ -498,24 +535,20 @@ class UsersApiV2 { throw StateError('User data not found'); } - // Update study dates - final now = DateTime.now(); - final studyDates = List.from(userData.studyDates ?? []); - studyDates.add(now); - // Update total study time + final now = DateTime.now(); final totalStudyTime = userData.totalStudyTimeMinutes + sessionDto.duration.inMinutes; - // Recalculate streak + // Recalculate streak from StudySessions (studyDates теперь рассчитывается на лету) + final studyDates = await _statisticsCalculator.calculateStudyDates(user.id!); final currentStreak = _statisticsCalculator.calculateStreak(studyDates); final longestStreak = max(userData.longestStreak, currentStreak); - // Update user data + // Update user data (без studyDates - это поле удалено, рассчитывается на лету) await _db.userDao.updateUserDataPartial( UserDatasCompanion( userId: drift.Value(user.id!), - studyDates: drift.Value(studyDates), totalStudyTimeMinutes: drift.Value(totalStudyTime), currentStreak: drift.Value(currentStreak), longestStreak: drift.Value(longestStreak), diff --git a/mnemo_cards_backend/lib/database/daos/pack_dao.dart b/mnemo_cards_backend/lib/database/daos/pack_dao.dart index f7709d1..52010cd 100644 --- a/mnemo_cards_backend/lib/database/daos/pack_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/pack_dao.dart @@ -123,6 +123,15 @@ class PackDao extends DatabaseAccessor with _$PackDaoMixin { return (select(gameCards)..where((c) => c.id.isIn(ids))).get(); } + /// Найти карточки по original (слову) + /// Возвращает все карточки с указанным original (может быть несколько) + Future> searchCardsByOriginal(String original) { + return (select(gameCards) + ..where((c) => c.original.equals(original) & c.isDeleted.equals(false)) + ..limit(10) // Ограничиваем для производительности + ).get(); + } + /// Создать карточку Future createCard(GameCardsCompanion card) async { final inserted = await into(gameCards).insertReturning(card); diff --git a/mnemo_cards_backend/lib/statistics/statistics_calculator.dart b/mnemo_cards_backend/lib/statistics/statistics_calculator.dart index 4cac536..b236ae3 100644 --- a/mnemo_cards_backend/lib/statistics/statistics_calculator.dart +++ b/mnemo_cards_backend/lib/statistics/statistics_calculator.dart @@ -4,7 +4,6 @@ import 'package:injectable/injectable.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_backend/database/database.dart'; -import 'package:drift_postgres/drift_postgres.dart'; /// Service for calculating various user statistics from raw data @lazySingleton @@ -139,10 +138,10 @@ class StatisticsCalculator { if (session.packId == null) continue; // Получить категорию пака - // Примечание: если в CardPacks нет поля category, используем 'unknown' + // Примечание: в CardPacks нет поля category, используем 'unknown' // В будущем можно добавить поле category в CardPacks или использовать другую логику final pack = await _db.packDao.getPackById(session.packId!); - final category = pack != null ? (pack.category ?? 'unknown') : 'unknown'; + final category = pack != null ? 'unknown' : 'unknown'; // TODO: добавить category в CardPacks // Рассчитать время сессии в минутах int sessionMinutes = 0; @@ -296,18 +295,42 @@ class StatisticsCalculator { } /// Get timeline statistics for a specific period - Map getTimelineStatistics( - UserDataModel data, { + /// + /// Примечание: теперь принимает рассчитанные данные вместо UserDataModel + /// для работы с новой структурой (без удаленных полей) + Future> getTimelineStatistics( + String userId, { required String period, DateTime? from, DateTime? to, - }) { + }) async { final now = DateTime.now(); final startDate = from ?? _getStartDateForPeriod(period, now); final endDate = to ?? now; - final dailyTime = calculateDailyStudyTime(data); - final studyDates = data.studyDates; + // Получить сессии для расчета времени + final sessions = await _db.statisticsDao.getSessionsByUserId( + userId, + fromDate: startDate, + toDate: endDate, + ); + + // Рассчитать ежедневное время из сессий + final dailyTime = {}; + for (final session in sessions) { + if (session.endTime != null && session.startTime != null) { + final date = DateTime( + session.startTime.dateTime.year, + session.startTime.dateTime.month, + session.startTime.dateTime.day, + ); + final duration = session.endTime!.dateTime.difference(session.startTime.dateTime); + dailyTime[date] = (dailyTime[date] ?? 0) + duration.inMinutes; + } + } + + // Получить studyDates + final studyDates = await calculateStudyDates(userId); // Filter data for the period final periodDailyTime = {}; @@ -334,7 +357,7 @@ class StatisticsCalculator { periodDailyTime.values.fold(0, (sum, time) => sum + time); final averageDailyMinutes = activeDays > 0 ? totalMinutes / activeDays : 0; - // Calculate streak in period + // Calculate streak in period (из рассчитанных studyDates) final periodStreak = calculateStreak(periodStudyDates); return { diff --git a/mnemo_cards_backend/lib/user/user_data_model.dart b/mnemo_cards_backend/lib/user/user_data_model.dart index d67f9eb..6e0651a 100644 --- a/mnemo_cards_backend/lib/user/user_data_model.dart +++ b/mnemo_cards_backend/lib/user/user_data_model.dart @@ -2,9 +2,18 @@ import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; extension UserDataModelExtension on UserDataModel { - UserDataDto toDto() { + /// Конвертация в DTO с использованием данных из модели + /// + /// Примечание: packProgress, studyDates, categoryMinutes теперь рассчитываются на лету + /// и должны передаваться как параметры + UserDataDto toDto({ + List? calculatedPackProgress, + List? calculatedStudyDates, + Map? calculatedCategoryMinutes, + AllWordsStatisticsDto? calculatedWordsStatistics, + }) { return UserDataDto( - allWordsStatistics: AllWordsStatisticsDto( + allWordsStatistics: calculatedWordsStatistics ?? AllWordsStatisticsDto( words: words.map((model) => model.toDto()).toList(), ), allTestsStatistics: AllTestsStatisticsDto( @@ -20,11 +29,11 @@ extension UserDataModelExtension on UserDataModel { totalStudyTimeMinutes: totalStudyTimeMinutes, currentStreak: currentStreak, longestStreak: longestStreak, - packProgress: { - for (final progress in packProgress) progress.packId: progress.toDto() - }, - studyDates: List.from(studyDates), - categoryMinutes: Map.from(categoryMinutes), + packProgress: calculatedPackProgress != null + ? {for (final progress in calculatedPackProgress) progress.packId: progress} + : {for (final progress in packProgress) progress.packId: progress.toDto()}, + studyDates: calculatedStudyDates ?? List.from(studyDates), + categoryMinutes: calculatedCategoryMinutes ?? Map.from(categoryMinutes), achievements: achievements.map((model) => model.toDto()).toList(), ); } diff --git a/mnemo_cards_backend/lib/user/user_manager.dart b/mnemo_cards_backend/lib/user/user_manager.dart index 36d5ea0..9a87507 100644 --- a/mnemo_cards_backend/lib/user/user_manager.dart +++ b/mnemo_cards_backend/lib/user/user_manager.dart @@ -55,7 +55,7 @@ class UserManager { if (token.expires.dateTime.isAfter(now)) { return token.token; } - await _db.userDao.deleteToken(token.id); + await _db.userDao.softDeleteToken(token.id); } final userToken = Secure.token(); await _db.userDao.createToken( @@ -76,7 +76,7 @@ class UserManager { } final now = DateTime.now(); if (token.expires.dateTime.isBefore(now)) { - await _db.userDao.deleteToken(token.id); + await _db.userDao.softDeleteToken(token.id); return null; } final user = await fetchUser(token.userId); @@ -253,22 +253,28 @@ class UserManager { if (cards.isNotEmpty) { final card = cards.first; - // Записать каждый ответ (correct/incorrect) + // Записать ответы // correct и incorrect в WordStatisticsDto - это уже агрегированные значения - // Но для простоты запишем их как один ответ - if (wordStat.correct > 0) { - await _wordStatisticsManager.recordAnswer( - userId: user.id!, - cardId: card.id, - isCorrect: true, - ); - } - if (wordStat.incorrect > 0) { - await _wordStatisticsManager.recordAnswer( - userId: user.id!, - cardId: card.id, - isCorrect: false, - ); + // Записываем их как несколько ответов для точности статистики + // Но для простоты запишем один раз с правильным количеством + // WordStatisticsManager.recordAnswer() обновит существующую запись + final totalAnswers = (wordStat.correct + wordStat.incorrect).toInt(); + if (totalAnswers > 0) { + // Записываем все ответы (правильные и неправильные) + for (var i = 0; i < wordStat.correct.toInt(); i++) { + await _wordStatisticsManager.recordAnswer( + userId: user.id!, + cardId: card.id, + isCorrect: true, + ); + } + for (var i = 0; i < wordStat.incorrect.toInt(); i++) { + await _wordStatisticsManager.recordAnswer( + userId: user.id!, + cardId: card.id, + isCorrect: false, + ); + } } } } diff --git a/mnemo_cards_backend/lib/user/user_model.dart b/mnemo_cards_backend/lib/user/user_model.dart index 41b55ba..4ea6964 100644 --- a/mnemo_cards_backend/lib/user/user_model.dart +++ b/mnemo_cards_backend/lib/user/user_model.dart @@ -3,6 +3,10 @@ import 'package:mnemo_cards_backend/user/user_data_model.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; extension UserModelExtension on UserModel { + /// Конвертация в DTO + /// + /// Примечание: для расчета packProgress, studyDates, categoryMinutes на лету + /// используйте toDtoWithCalculatedData() Future toDto() async { final activeSubscription = subscriptionModel != null && subscriptionModel!.isActive; return UserDto( @@ -16,4 +20,32 @@ extension UserModelExtension on UserModel { userDataDto: userData?.toDto(), ); } + + /// Конвертация в DTO с рассчитанными данными на лету + /// + /// Используется когда нужно рассчитать packProgress, studyDates, categoryMinutes + /// из WordStatistics и StudySessions вместо использования удаленных полей UserDatas + Future toDtoWithCalculatedData({ + required List packProgress, + required List studyDates, + required Map categoryMinutes, + AllWordsStatisticsDto? wordsStatistics, + }) async { + final activeSubscription = subscriptionModel != null && subscriptionModel!.isActive; + return UserDto( + id: id, + name: name, + email: email, + admin: admin, + packs: packs.map((e) => e.id?.toString()).whereNotNull().toList(), + subscription: activeSubscription, + subscriptionFeatures: subscriptionModel?.features.toSet() ?? {}, + userDataDto: userData?.toDto( + calculatedPackProgress: packProgress, + calculatedStudyDates: studyDates, + calculatedCategoryMinutes: categoryMinutes, + calculatedWordsStatistics: wordsStatistics, + ), + ); + } }