mnemo_cards/mnemo_cards_backend/lib/user/user_manager.dart
Dmitry e4e6a259c7
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (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
backaend + admin
2025-12-14 23:42:38 +03:00

297 lines
No EOL
10 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_drift_extension.dart';
Map<String?, DateTime> _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<UserModel?> 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<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.deleteToken(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.deleteToken(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,
required String email,
String? name,
}) async {
// Check if user exists by externalId
final existingUser = await _db.userDao.getUserByExternalId(externalId);
if (existingUser != null) {
print('User found $name $email');
final userModel = await existingUser.toUserModel();
final token = await createOrGetAuthToken(userModel, externalId);
return (userModel, token);
}
print('Creating new user $name $email');
// 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),
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 created successfully');
return (userModel, token);
}
/// Обновить настройки пользователя
Future<void> 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<void> 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);
// Обновить результаты теста
// results хранится как Map<String, dynamic>, где ключ 'attempts' содержит список попыток
final currentResults = existingStat?.results ?? <String, dynamic>{};
final attemptsKey = 'attempts';
final currentAttempts = (currentResults[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 updatedResults = <String, dynamic>{
...currentResults,
attemptsKey: updatedAttempts,
};
if (existingStat != null) {
// Обновить существующую статистику
final updatedStat = existingStat.copyWith(
results: drift.Value(updatedResults),
updatedAt: PgDateTime(DateTime.now()),
);
await _db.testDao.updateTestStatistics(updatedStat);
} else {
// Создать новую статистику
await _db.testDao.createTestStatistics(
TestStatisticsCompanion.insert(
userId: user.id!,
testId: testStat.testId,
results: drift.Value(updatedResults),
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)
// correct и incorrect в WordStatisticsDto - это уже агрегированные значения
// Но для простоты запишем их как один ответ
if (wordStat.correct > 0) {
await _wordStatisticsManager.recordAnswer(
userId: user.id!,
cardId: card.id,
isCorrect: true,
);
}
if (wordStat.incorrect > 0) {
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)),
),
);
}
}