mnemo_cards/mnemo_cards_backend/lib/user/user_manager.dart
Dmitry aa30ef516a
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Mobile App CI / test (push) Waiting to run
Mobile App CI / build-android (push) Blocked by required conditions
Mobile App CI / build-ios (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App 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
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run
fixes and stuff
2025-12-18 23:40:48 +03:00

328 lines
No EOL
12 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.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
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<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);
// Обновить результаты теста
// 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)),
),
);
}
}