Some checks are pending
Backend CI / test (push) Waiting to run
Backend 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
338 lines
11 KiB
Dart
338 lines
11 KiB
Dart
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)),
|
||
),
|
||
);
|
||
}
|
||
}
|