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 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
343 lines
12 KiB
Dart
343 lines
12 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_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)),
|
||
),
|
||
);
|
||
}
|
||
}
|