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_drift_extension.dart'; Map _onlineUsers = {}; @lazySingleton class UserManager { final AppDatabase _db; final FreePacksDistributor _freePacksDistributor; final SessionTracker _sessionTracker; final StatisticsCalculator _statisticsCalculator; final AchievementManager _achievementManager; final WordStatisticsManager _wordStatisticsManager; UserManager( this._db, this._freePacksDistributor, this._sessionTracker, this._statisticsCalculator, this._achievementManager, this._wordStatisticsManager, ); Future fetchUser(String id) async { final user = await _db.userDao.getUserById(id); if (user == null) return null; return await user.toUserModel(); } 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 final existingUser = await _db.userDao.getUserByExternalId(externalId); if (existingUser != null) { print('User found $name $email $telegram'); // Обновляем контактные данные, если они пришли впервые/изменились final shouldUpdateEmail = email != null && email.isNotEmpty && existingUser.email != email; final shouldUpdateTelegram = telegram != null && telegram.isNotEmpty && existingUser.telegram != telegram; if (shouldUpdateEmail || shouldUpdateTelegram) { await _db.userDao.updateUserPartial( UsersCompanion( id: drift.Value(existingUser.id), email: shouldUpdateEmail ? drift.Value(email) : const drift.Value.absent(), telegram: shouldUpdateTelegram ? drift.Value(telegram) : const drift.Value.absent(), updatedAt: drift.Value(PgDateTime(DateTime.now())), ), ); } final userModel = await existingUser.toUserModel(); final token = await createOrGetAuthToken(userModel, externalId); return (userModel, token); } print('Creating new user $name $email $telegram'); // Create new user with user data in transaction final now = DateTime.now(); final userCompanion = UsersCompanion.insert( externalUserId: externalId, name: drift.Value(name), email: drift.Value(email), telegram: drift.Value(telegram), admin: drift.Value(false), purchases: drift.Value([]), createdAt: drift.Value(PgDateTime(now)), updatedAt: drift.Value(PgDateTime(now)), isDeleted: drift.Value(false), ); final userDataCompanion = UserDatasCompanion.insert( userId: '', // Will be set by createUserWithData via copyWith registrationDate: drift.Value(PgDateTime(now)), ); final userId = await _db.userDao.createUserWithData( user: userCompanion, userData: userDataCompanion, ); final user = await _db.userDao.getUserById(userId); if (user == null) { throw Exception('Failed to create user'); } final userModel = await user.toUserModel(); final token = await createOrGetAuthToken(userModel, externalId); // Give free packs to new user await _freePacksDistributor.giveFreePacksToUser(userModel); print('User $name $email $telegram created successfully'); return (userModel, token); } /// Обновить настройки пользователя Future updateUserSettings(UserModel user, UserSettingsDto settings) async { if (user.id == null) { throw Exception('User ID is required'); } await _db.userDao.updateUserPartial( UsersCompanion( id: drift.Value(user.id!), userSettings: drift.Value(jsonEncode(settings.toJson())), updatedAt: drift.Value(PgDateTime(DateTime.now())), ), ); } /// Добавить статистику теста Future addTestStatistics(UserModel user, TestStatisticsDto testStat) async { if (user.id == null) { throw Exception('User ID is required'); } // Получить или создать UserData var userData = await _db.userDao.getUserData(user.id!); if (userData == null) { await _db.userDao.createUserData( UserDatasCompanion.insert(userId: user.id!), ); userData = await _db.userDao.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)), ), ); } }