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-08 16:07:56 +00:00
|
|
|
|
import 'user_repository.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;
|
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,
|
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();
|
2025-12-13 13:27:05 +00:00
|
|
|
|
final token = await _db.userDao.getTokenByUserId(user.id!);
|
|
|
|
|
|
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
|
|
|
|
}
|
2025-12-14 20:45:27 +00:00
|
|
|
|
await _db.userDao.softDeleteToken(token.id);
|
2025-11-16 11:25:27 +00:00
|
|
|
|
}
|
|
|
|
|
|
final userToken = Secure.token();
|
2025-12-13 13:27:05 +00:00
|
|
|
|
await _db.userDao.createToken(
|
|
|
|
|
|
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 {
|
2025-12-13 13:27:05 +00:00
|
|
|
|
final token = await _db.userDao.getTokenByValue(authToken);
|
|
|
|
|
|
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)) {
|
2025-12-14 20:45:27 +00:00
|
|
|
|
await _db.userDao.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) {
|
|
|
|
|
|
await _db.userDao.updateUserDataPartial(
|
|
|
|
|
|
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) {
|
|
|
|
|
|
await _db.userDao.createUserData(
|
|
|
|
|
|
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, берем первую
|
|
|
|
|
|
final cards = await _db.packDao.searchCardsByOriginal(wordStat.word);
|
|
|
|
|
|
if (cards.isNotEmpty) {
|
|
|
|
|
|
final card = cards.first;
|
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 - это поле удалено)
|
2025-12-13 14:48:00 +00:00
|
|
|
|
await _db.userDao.updateUserDataPartial(
|
|
|
|
|
|
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
|
|
|
|
}
|