mnemo_cards/mnemo_cards_backend/lib/user/user_manager.dart

345 lines
12 KiB
Dart
Raw Normal View History

2025-12-13 14:48:00 +00:00
import 'dart:convert';
2025-11-16 11:25:27 +00:00
import 'dart:developer';
2025-12-13 14:48:00 +00:00
import 'dart:math' as math;
2025-11-16 11:25:27 +00:00
2025-12-13 23:35:14 +00:00
import 'package:drift_postgres/drift_postgres.dart';
2025-11-16 11:25:27 +00:00
import 'package:injectable/injectable.dart';
2025-12-13 13:27:05 +00:00
import 'package:drift/drift.dart' as drift;
import 'package:mnemo_cards_backend/database/database.dart';
2025-11-16 11:25:27 +00:00
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';
2025-12-14 20:42:38 +00:00
import '../statistics/word_statistics_manager.dart';
2025-11-16 11:25:27 +00:00
import 'secure.dart';
2026-01-09 17:21:18 +00:00
import '../repository/export.dart';
2025-11-16 11:25:27 +00:00
2025-12-13 20:55:50 +00:00
Map<String?, DateTime> _onlineUsers = {};
2025-11-16 11:25:27 +00:00
@lazySingleton
class UserManager {
2025-12-13 13:27:05 +00:00
final AppDatabase _db;
2026-01-08 16:07:56 +00:00
final UserRepository _userRepository;
2026-01-09 17:21:18 +00:00
final PackRepository _packRepository;
2025-11-16 11:25:27 +00:00
final FreePacksDistributor _freePacksDistributor;
2025-12-13 14:48:00 +00:00
final SessionTracker _sessionTracker;
final StatisticsCalculator _statisticsCalculator;
final AchievementManager _achievementManager;
2025-12-14 20:42:38 +00:00
final WordStatisticsManager _wordStatisticsManager;
2025-11-16 11:25:27 +00:00
UserManager(
2025-12-13 13:27:05 +00:00
this._db,
2026-01-08 16:07:56 +00:00
this._userRepository,
2026-01-09 17:21:18 +00:00
this._packRepository,
2025-11-16 11:25:27 +00:00
this._freePacksDistributor,
2025-12-13 14:48:00 +00:00
this._sessionTracker,
this._statisticsCalculator,
this._achievementManager,
2025-12-14 20:42:38 +00:00
this._wordStatisticsManager,
2025-11-16 11:25:27 +00:00
);
2025-12-13 20:55:50 +00:00
Future<UserModel?> fetchUser(String id) async {
2026-01-08 16:07:56 +00:00
return await _userRepository.getUserById(id, includePacks: true);
2025-11-16 11:25:27 +00:00
}
2025-12-13 20:55:50 +00:00
DateTime? lastOnline(String id) => _onlineUsers[id];
2025-11-16 11:25:27 +00:00
Future<String> createOrGetAuthToken(UserModel user, String externalId) async {
if (user.id == null) {
throw Exception('Cant create token for empty id');
}
final now = DateTime.now();
2026-01-09 17:21:18 +00:00
final token = await _userRepository.getTokenByUserId(user.id!);
2025-12-13 13:27:05 +00:00
if (token != null) {
2025-12-13 23:35:14 +00:00
if (token.expires.dateTime.isAfter(now)) {
2025-12-13 13:27:05 +00:00
return token.token;
2025-11-16 11:25:27 +00:00
}
2026-01-09 17:21:18 +00:00
await _userRepository.softDeleteToken(token.id);
2025-11-16 11:25:27 +00:00
}
final userToken = Secure.token();
2026-01-09 17:21:18 +00:00
await _userRepository.createToken(
2025-12-13 13:27:05 +00:00
TokensCompanion.insert(
2025-11-16 11:25:27 +00:00
token: userToken,
externalUserId: externalId,
userId: user.id!,
2025-12-13 23:35:14 +00:00
expires: PgDateTime(now.add(const Duration(days: 360))),
2025-11-16 11:25:27 +00:00
),
);
return userToken;
}
Future<UserModel?> getUserByToken(String authToken) async {
2026-01-09 17:21:18 +00:00
final token = await _userRepository.getTokenByValue(authToken);
2025-12-13 13:27:05 +00:00
if (token == null) {
2025-11-16 11:25:27 +00:00
return null;
}
final now = DateTime.now();
2025-12-13 23:35:14 +00:00
if (token.expires.dateTime.isBefore(now)) {
2026-01-09 17:21:18 +00:00
await _userRepository.softDeleteToken(token.id);
2025-11-16 11:25:27 +00:00
return null;
}
2025-12-13 13:27:05 +00:00
final user = await fetchUser(token.userId);
if (user != null) {
_onlineUsers[token.userId] = now;
2025-11-16 11:25:27 +00:00
if (_onlineUsers.length > 300) {
updateOnlineUsers();
}
}
return user;
}
Future<void> updateOnlineUsers() async {
if (_onlineUsers.isEmpty) {
return;
}
2025-12-13 13:27:05 +00:00
final userIds = _onlineUsers.keys.whereNotNull().toList();
for (final userId in userIds) {
final lastOnline = _onlineUsers[userId];
if (lastOnline != null) {
2026-01-09 17:21:18 +00:00
await _userRepository.updateUserDataPartial(
2025-12-13 13:27:05 +00:00
UserDatasCompanion(
userId: drift.Value(userId),
2025-12-13 23:35:14 +00:00
lastTimeOnline: drift.Value(PgDateTime(lastOnline)),
updatedAt: drift.Value(PgDateTime(DateTime.now())),
2025-12-13 13:27:05 +00:00
),
);
}
}
_onlineUsers.clear();
2025-11-16 11:25:27 +00:00
}
Future<(UserModel, String)> createOrGetUser({
required String externalId,
2025-12-18 20:40:48 +00:00
String? email,
String? telegram,
2025-11-16 11:25:27 +00:00
String? name,
}) async {
2025-12-13 13:27:05 +00:00
// Check if user exists by externalId
2026-01-08 16:58:13 +00:00
var existingUserModel = await _userRepository.getUserByExternalId(
externalId,
);
2026-01-08 16:07:56 +00:00
if (existingUserModel != null) {
2025-12-18 20:40:48 +00:00
print('User found $name $email $telegram');
// Обновляем контактные данные, если они пришли впервые/изменились
final shouldUpdateEmail =
2026-01-08 16:07:56 +00:00
email != null && email.isNotEmpty && existingUserModel.email != email;
2025-12-20 18:26:15 +00:00
final shouldUpdateTelegram =
telegram != null &&
2025-12-18 20:40:48 +00:00
telegram.isNotEmpty &&
2026-01-08 16:07:56 +00:00
existingUserModel.telegram != telegram;
2025-12-18 20:40:48 +00:00
if (shouldUpdateEmail || shouldUpdateTelegram) {
2026-01-08 16:07:56 +00:00
final updatedModel = existingUserModel.copyWith(
email: shouldUpdateEmail ? email : existingUserModel.email,
2026-01-08 16:58:13 +00:00
telegram: shouldUpdateTelegram
? telegram
: existingUserModel.telegram,
2025-12-18 20:40:48 +00:00
);
2026-01-08 16:07:56 +00:00
await _userRepository.updateUser(updatedModel);
existingUserModel = updatedModel;
2025-12-18 20:40:48 +00:00
}
2026-01-08 16:07:56 +00:00
final token = await createOrGetAuthToken(existingUserModel, externalId);
return (existingUserModel, token);
2025-11-16 11:25:27 +00:00
}
2025-12-18 20:40:48 +00:00
print('Creating new user $name $email $telegram');
2025-11-16 11:25:27 +00:00
2025-12-14 00:13:49 +00:00
// 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)),
);
2025-11-16 11:25:27 +00:00
2026-01-08 16:07:56 +00:00
// Создаем пользователя через 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,
2025-12-14 00:13:49 +00:00
userData: userDataCompanion,
2025-12-13 13:27:05 +00:00
);
2025-11-16 11:25:27 +00:00
2026-01-08 16:58:13 +00:00
final createdUserModel = await _userRepository.getUserById(
userId,
includePacks: true,
);
2026-01-08 16:07:56 +00:00
if (createdUserModel == null) {
2025-12-13 13:27:05 +00:00
throw Exception('Failed to create user');
2025-11-16 11:25:27 +00:00
}
2026-01-08 16:07:56 +00:00
final token = await createOrGetAuthToken(createdUserModel, externalId);
2025-11-16 11:25:27 +00:00
2025-12-13 13:27:05 +00:00
// Give free packs to new user
2026-01-08 16:07:56 +00:00
await _freePacksDistributor.giveFreePacksToUser(createdUserModel);
2025-11-16 11:25:27 +00:00
2025-12-18 20:40:48 +00:00
print('User $name $email $telegram created successfully');
2026-01-08 16:07:56 +00:00
return (createdUserModel, token);
2025-11-16 11:25:27 +00:00
}
2025-12-13 14:48:00 +00:00
/// Обновить настройки пользователя
2025-12-20 18:26:15 +00:00
Future<void> updateUserSettings(
UserModel user,
UserSettingsDto settings,
) async {
2025-12-13 14:48:00 +00:00
if (user.id == null) {
throw Exception('User ID is required');
}
2026-01-08 16:07:56 +00:00
final updatedUser = user.copyWith(
userSettings: jsonEncode(settings.toJson()),
2025-12-13 14:48:00 +00:00
);
2026-01-08 16:07:56 +00:00
await _userRepository.updateUser(updatedUser);
2025-12-13 14:48:00 +00:00
}
/// Добавить статистику теста
2025-12-20 18:26:15 +00:00
Future<void> addTestStatistics(
UserModel user,
TestStatisticsDto testStat,
) async {
2025-12-13 14:48:00 +00:00
if (user.id == null) {
throw Exception('User ID is required');
}
// Получить или создать UserData
2026-01-08 16:07:56 +00:00
var userData = await _userRepository.getUserData(user.id!);
2025-12-13 14:48:00 +00:00
if (userData == null) {
2026-01-09 17:21:18 +00:00
await _userRepository.createUserData(
2025-12-13 14:48:00 +00:00
UserDatasCompanion.insert(userId: user.id!),
);
2026-01-08 16:07:56 +00:00
userData = await _userRepository.getUserData(user.id!);
2025-12-13 14:48:00 +00:00
if (userData == null) {
throw Exception('Failed to create user data');
}
}
// Проверить, не был ли этот sessionToken уже обработан
if (userData.lastTestSessionToken == testStat.sessionToken) {
log('Old session token, skipping');
return;
}
// Получить или создать TestStatistic
2025-12-20 18:26:15 +00:00
final existingStat = await _db.testDao.getTestStatistics(
user.id!,
testStat.testId,
);
2025-12-13 14:48:00 +00:00
// Обновить результаты теста
2025-12-17 00:56:22 +00:00
// metadata хранится как JSON string, где ключ 'attempts' содержит список попыток
final currentMetadata = existingStat?.metadata.isNotEmpty == true
? (json.decode(existingStat!.metadata) as Map<String, dynamic>? ?? {})
: <String, dynamic>{};
2025-12-13 14:48:00 +00:00
final attemptsKey = 'attempts';
2025-12-20 18:26:15 +00:00
final currentAttempts =
(currentMetadata[attemptsKey] as List<dynamic>?) ?? [];
2025-12-13 14:48:00 +00:00
final newAttempt = <String, dynamic>{
'sessionToken': testStat.sessionToken,
'words': testStat.words.words.map((w) => w.toJson()).toList(),
};
2025-12-20 18:26:15 +00:00
2025-12-13 14:48:00 +00:00
final updatedAttempts = <dynamic>[...currentAttempts, newAttempt];
2025-12-17 00:56:22 +00:00
final updatedMetadata = <String, dynamic>{
...currentMetadata,
2025-12-13 14:48:00 +00:00
attemptsKey: updatedAttempts,
};
if (existingStat != null) {
// Обновить существующую статистику
final updatedStat = existingStat.copyWith(
2025-12-17 00:56:22 +00:00
metadata: json.encode(updatedMetadata),
2025-12-13 23:35:14 +00:00
updatedAt: PgDateTime(DateTime.now()),
2025-12-13 14:48:00 +00:00
);
await _db.testDao.updateTestStatistics(updatedStat);
} else {
// Создать новую статистику
await _db.testDao.createTestStatistics(
TestStatisticsCompanion.insert(
userId: user.id!,
testId: testStat.testId,
2025-12-17 00:56:22 +00:00
metadata: drift.Value(json.encode(updatedMetadata)),
2025-12-13 23:35:14 +00:00
completedAt: drift.Value(PgDateTime(DateTime.now())),
2025-12-13 14:48:00 +00:00
),
);
}
2025-12-14 20:42:38 +00:00
// Записать статистику по словам в WordStatistics
// Для каждого слова из теста находим карточку и записываем ответ
for (final wordStat in testStat.words.words) {
// Найти карточку по слову (original)
// Примечание: если несколько карточек с одинаковым original, берем первую
2026-01-09 17:21:18 +00:00
final cardModels = await _packRepository.searchCardsByOriginal(
wordStat.word,
);
if (cardModels.isNotEmpty) {
final cardModel = cardModels.first;
final card = await _db.packDao.getCardById(cardModel.id!);
if (card == null) continue;
2025-12-20 18:26:15 +00:00
2025-12-14 20:45:27 +00:00
// Записать ответы
2025-12-14 20:42:38 +00:00
// correct и incorrect в WordStatisticsDto - это уже агрегированные значения
2025-12-14 20:45:27 +00:00
// Записываем их как несколько ответов для точности статистики
// Но для простоты запишем один раз с правильным количеством
// 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,
);
}
2025-12-14 20:42:38 +00:00
}
}
2025-12-13 14:48:00 +00:00
}
2025-12-14 20:42:38 +00:00
// Обновить статистику пользователя
// studyDates теперь рассчитывается из StudySessions, не хранится в UserDatas
final now = DateTime.now();
2025-12-20 18:26:15 +00:00
2025-12-14 20:42:38 +00:00
// Пересчитать streak из StudySessions
2025-12-20 18:26:15 +00:00
final studyDates = await _statisticsCalculator.calculateStudyDates(
user.id!,
);
2025-12-13 14:48:00 +00:00
final currentStreak = _statisticsCalculator.calculateStreak(studyDates);
final longestStreak = math.max(userData.longestStreak, currentStreak);
2025-12-14 20:42:38 +00:00
// Обновить UserData (без studyDates - это поле удалено)
2026-01-09 17:21:18 +00:00
await _userRepository.updateUserDataPartial(
2025-12-13 14:48:00 +00:00
UserDatasCompanion(
userId: drift.Value(user.id!),
lastTestSessionToken: drift.Value(testStat.sessionToken),
currentStreak: drift.Value(currentStreak),
longestStreak: drift.Value(longestStreak),
2025-12-13 23:35:14 +00:00
lastTimeOnline: drift.Value(PgDateTime(now)),
updatedAt: drift.Value(PgDateTime(now)),
2025-12-13 14:48:00 +00:00
),
);
}
2025-12-20 18:26:15 +00:00
}