import 'dart:convert'; import 'dart:developer'; import 'dart:math' as math; import 'package:drift_postgres/drift_postgres.dart'; import 'package:injectable/injectable.dart'; import 'package:drift/drift.dart' as drift; import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import '../packs/free_packs_distributor.dart'; import '../statistics/session_tracker.dart'; import '../statistics/statistics_calculator.dart'; import '../statistics/achievement_manager.dart'; import '../statistics/word_statistics_manager.dart'; import 'secure.dart'; import 'user_repository.dart'; Map _onlineUsers = {}; @lazySingleton class UserManager { final AppDatabase _db; final UserRepository _userRepository; final FreePacksDistributor _freePacksDistributor; final SessionTracker _sessionTracker; final StatisticsCalculator _statisticsCalculator; final AchievementManager _achievementManager; final WordStatisticsManager _wordStatisticsManager; UserManager( this._db, this._userRepository, this._freePacksDistributor, this._sessionTracker, this._statisticsCalculator, this._achievementManager, this._wordStatisticsManager, ); Future fetchUser(String id) async { return await _userRepository.getUserById(id, includePacks: true); } DateTime? lastOnline(String id) => _onlineUsers[id]; Future createOrGetAuthToken(UserModel user, String externalId) async { if (user.id == null) { throw Exception('Cant create token for empty id'); } final now = DateTime.now(); final token = await _db.userDao.getTokenByUserId(user.id!); if (token != null) { if (token.expires.dateTime.isAfter(now)) { return token.token; } await _db.userDao.softDeleteToken(token.id); } final userToken = Secure.token(); await _db.userDao.createToken( TokensCompanion.insert( token: userToken, externalUserId: externalId, userId: user.id!, expires: PgDateTime(now.add(const Duration(days: 360))), ), ); return userToken; } Future getUserByToken(String authToken) async { final token = await _db.userDao.getTokenByValue(authToken); if (token == null) { return null; } final now = DateTime.now(); if (token.expires.dateTime.isBefore(now)) { await _db.userDao.softDeleteToken(token.id); return null; } final user = await fetchUser(token.userId); if (user != null) { _onlineUsers[token.userId] = now; if (_onlineUsers.length > 300) { updateOnlineUsers(); } } return user; } Future updateOnlineUsers() async { if (_onlineUsers.isEmpty) { return; } final userIds = _onlineUsers.keys.whereNotNull().toList(); for (final userId in userIds) { final lastOnline = _onlineUsers[userId]; if (lastOnline != null) { await _db.userDao.updateUserDataPartial( UserDatasCompanion( userId: drift.Value(userId), lastTimeOnline: drift.Value(PgDateTime(lastOnline)), updatedAt: drift.Value(PgDateTime(DateTime.now())), ), ); } } _onlineUsers.clear(); } Future<(UserModel, String)> createOrGetUser({ required String externalId, String? email, String? telegram, String? name, }) async { // Check if user exists by externalId var existingUserModel = await _userRepository.getUserByExternalId(externalId); if (existingUserModel != null) { print('User found $name $email $telegram'); // Обновляем контактные данные, если они пришли впервые/изменились final shouldUpdateEmail = email != null && email.isNotEmpty && existingUserModel.email != email; final shouldUpdateTelegram = telegram != null && telegram.isNotEmpty && existingUserModel.telegram != telegram; if (shouldUpdateEmail || shouldUpdateTelegram) { final updatedModel = existingUserModel.copyWith( email: shouldUpdateEmail ? email : existingUserModel.email, telegram: shouldUpdateTelegram ? telegram : existingUserModel.telegram, ); await _userRepository.updateUser(updatedModel); existingUserModel = updatedModel; } final token = await createOrGetAuthToken(existingUserModel, externalId); return (existingUserModel, token); } print('Creating new user $name $email $telegram'); // Create new user with user data in transaction final now = DateTime.now(); final userDataCompanion = UserDatasCompanion.insert( userId: '', // Will be set by createUserWithData via copyWith registrationDate: drift.Value(PgDateTime(now)), ); // Создаем пользователя через UserRepository final newUserModel = UserModel( id: null, name: name, email: email, telegram: telegram, admin: false, purchases: [], userSettings: null, packs: [], ); final userId = await _userRepository.createUserWithData( userModel: newUserModel, userData: userDataCompanion, ); final createdUserModel = await _userRepository.getUserById(userId, includePacks: true); if (createdUserModel == null) { throw Exception('Failed to create user'); } final token = await createOrGetAuthToken(createdUserModel, externalId); // Give free packs to new user await _freePacksDistributor.giveFreePacksToUser(createdUserModel); print('User $name $email $telegram created successfully'); return (createdUserModel, token); } /// Обновить настройки пользователя Future updateUserSettings( UserModel user, UserSettingsDto settings, ) async { if (user.id == null) { throw Exception('User ID is required'); } final updatedUser = user.copyWith( userSettings: jsonEncode(settings.toJson()), ); await _userRepository.updateUser(updatedUser); } /// Добавить статистику теста Future addTestStatistics( UserModel user, TestStatisticsDto testStat, ) async { if (user.id == null) { throw Exception('User ID is required'); } // Получить или создать UserData var userData = await _userRepository.getUserData(user.id!); if (userData == null) { await _db.userDao.createUserData( UserDatasCompanion.insert(userId: user.id!), ); userData = await _userRepository.getUserData(user.id!); if (userData == null) { throw Exception('Failed to create user data'); } } // Проверить, не был ли этот sessionToken уже обработан if (userData.lastTestSessionToken == testStat.sessionToken) { log('Old session token, skipping'); return; } // Получить или создать TestStatistic final existingStat = await _db.testDao.getTestStatistics( user.id!, testStat.testId, ); // Обновить результаты теста // metadata хранится как JSON string, где ключ 'attempts' содержит список попыток final currentMetadata = existingStat?.metadata.isNotEmpty == true ? (json.decode(existingStat!.metadata) as Map? ?? {}) : {}; final attemptsKey = 'attempts'; final currentAttempts = (currentMetadata[attemptsKey] as List?) ?? []; final newAttempt = { 'sessionToken': testStat.sessionToken, 'words': testStat.words.words.map((w) => w.toJson()).toList(), }; final updatedAttempts = [...currentAttempts, newAttempt]; final updatedMetadata = { ...currentMetadata, attemptsKey: updatedAttempts, }; if (existingStat != null) { // Обновить существующую статистику final updatedStat = existingStat.copyWith( metadata: json.encode(updatedMetadata), updatedAt: PgDateTime(DateTime.now()), ); await _db.testDao.updateTestStatistics(updatedStat); } else { // Создать новую статистику await _db.testDao.createTestStatistics( TestStatisticsCompanion.insert( userId: user.id!, testId: testStat.testId, metadata: drift.Value(json.encode(updatedMetadata)), completedAt: drift.Value(PgDateTime(DateTime.now())), ), ); } // Записать статистику по словам в WordStatistics // Для каждого слова из теста находим карточку и записываем ответ for (final wordStat in testStat.words.words) { // Найти карточку по слову (original) // Примечание: если несколько карточек с одинаковым original, берем первую final cards = await _db.packDao.searchCardsByOriginal(wordStat.word); if (cards.isNotEmpty) { final card = cards.first; // Записать ответы // correct и incorrect в WordStatisticsDto - это уже агрегированные значения // Записываем их как несколько ответов для точности статистики // Но для простоты запишем один раз с правильным количеством // 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, ); } } } } // Обновить статистику пользователя // studyDates теперь рассчитывается из StudySessions, не хранится в UserDatas final now = DateTime.now(); // Пересчитать streak из StudySessions final studyDates = await _statisticsCalculator.calculateStudyDates( user.id!, ); final currentStreak = _statisticsCalculator.calculateStreak(studyDates); final longestStreak = math.max(userData.longestStreak, currentStreak); // Обновить UserData (без studyDates - это поле удалено) await _db.userDao.updateUserDataPartial( UserDatasCompanion( userId: drift.Value(user.id!), lastTestSessionToken: drift.Value(testStat.sessionToken), currentStreak: drift.Value(currentStreak), longestStreak: drift.Value(longestStreak), lastTimeOnline: drift.Value(PgDateTime(now)), updatedAt: drift.Value(PgDateTime(now)), ), ); } }