mnemo_cards/mnemo_cards_backend/lib/user/user_manager.dart
Dmitry a6abcfa7e6
Some checks failed
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
Mobile App CI / test (push) Has been cancelled
Deploy Telegram Bot / Deploy Telegram Bot (push) Has been cancelled
Mobile App CI / build-android (push) Has been cancelled
Mobile App CI / build-ios (push) Has been cancelled
repo
2026-01-08 19:07:56 +03:00

331 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String?, DateTime> _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<UserModel?> fetchUser(String id) async {
return await _userRepository.getUserById(id, includePacks: true);
}
DateTime? lastOnline(String id) => _onlineUsers[id];
Future<String> 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<UserModel?> 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<void> 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<void> 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<void> 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<String, dynamic>? ?? {})
: <String, dynamic>{};
final attemptsKey = 'attempts';
final currentAttempts =
(currentMetadata[attemptsKey] as List<dynamic>?) ?? [];
final newAttempt = <String, dynamic>{
'sessionToken': testStat.sessionToken,
'words': testStat.words.words.map((w) => w.toJson()).toList(),
};
final updatedAttempts = <dynamic>[...currentAttempts, newAttempt];
final updatedMetadata = <String, dynamic>{
...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)),
),
);
}
}