mnemo_cards/mnemo_cards_backend/lib/user/user_manager.dart
2025-11-16 15:15:21 +03:00

486 lines
16 KiB
Dart

import 'dart:developer';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import '../main.dart';
import '../packs/free_packs_distributor.dart';
import '../statistics/session_tracker.dart';
import '../statistics/statistics_calculator.dart';
import '../statistics/achievement_manager.dart';
import 'secure.dart';
Map<Id?, DateTime> _onlineUsers = {};
@lazySingleton
class UserManager {
final FreePacksDistributor _freePacksDistributor;
final SessionTracker _sessionTracker;
final StatisticsCalculator _statisticsCalculator;
final AchievementManager _achievementManager;
UserManager(
this._freePacksDistributor,
this._sessionTracker,
this._statisticsCalculator,
this._achievementManager,
);
Future<UserModel?> fetchUser(Id id) async {
final user = await isar.userModels.get(id);
return user;
}
DateTime? lastOnline(Id 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 tokenModel =
await isar.tokenModels.filter().userIdEqualTo(user.id!).findFirst();
if (tokenModel != null) {
if (tokenModel.expires.isAfter(now)) {
return tokenModel.token;
}
await isar.tokenModels.delete(tokenModel.id!);
}
final userToken = Secure.token();
await isar.tokenModels.put(
TokenModel(
token: userToken,
externalUserId: externalId,
userId: user.id!,
created: now,
expires: now.add(Duration(days: 360)),
),
);
return userToken;
}
Future<UserModel?> getUserByToken(String authToken) async {
final tokenModel =
await isar.tokenModels.filter().tokenEqualTo(authToken).findFirst();
if (tokenModel == null) {
return null;
}
final now = DateTime.now();
if (tokenModel.expires.isBefore(now)) {
isar.writeTxn(() => isar.tokenModels.delete(tokenModel.id!));
return null;
}
final user = await isar.userModels.get(tokenModel.userId);
if (user == null) {
return null;
} else {
_onlineUsers[user.id] = now;
if (_onlineUsers.length > 300) {
updateOnlineUsers();
}
}
return user;
}
Future<void> updateOnlineUsers() async {
if (_onlineUsers.isEmpty) {
return;
}
return isar.writeTxn(() async {
final ids = _onlineUsers.keys.whereNotNull().toList();
final users = (await isar.userModels.getAll(ids)).whereNotNull();
await Future.wait(users.map((user) => user.userData.load()));
final updated = users
.map((user) => user.userData.value?.copyWith(
lastTimeOnline: _onlineUsers[user.id] ??
user.userData.value?.lastTimeOnline,
))
.whereNotNull()
.toList();
await isar.userDataModels.putAll(updated);
_onlineUsers.clear();
});
}
Future<List<TokenModel>> getTokensByUser(String userId) async {
final id = int.tryParse(userId);
if (id == null) return [];
return isar.txn(
() => isar.tokenModels.filter().userIdEqualTo(id).findAll(),
);
}
Future<(UserModel, String)> createOrGetUser({
required String externalId,
required String email,
String? name,
}) async {
final tokenModel = await isar.tokenModels
.filter()
.externalUserIdEqualTo(externalId)
.findFirst();
var user = tokenModel == null ? null : await fetchUser(tokenModel.userId);
if (user == null) {
print('Creating new user $name $email');
final modelAndToken = await isar.writeTxn(() async {
final userModel = UserModel.empty.copyWith(
email: email,
name: name,
);
await isar.userModels.put(userModel);
print('User $name $email saved');
await isar.tokenModels
.filter()
.externalUserIdEqualTo(externalId)
.deleteFirst();
print('User tokens deleted $name $email');
final token = await createOrGetAuthToken(userModel, externalId);
final userData = UserDataModel()..user.value = userModel;
await isar.userDataModels.put(userData);
userModel.userData.value = userData;
await userModel.userData.save();
return (userModel, token);
});
await _freePacksDistributor.giveFreePacksToUser(modelAndToken.$1);
return modelAndToken;
} else {
if (user.email == null) {
user = user.copyWith.email(email);
await isar.writeTxn(() => isar.userModels.put(user!));
}
await isar.writeTxn(() async {
await user!.userData.load();
if (user.userData.value == null) {
final userData = UserDataModel()..user.value = user;
await isar.userDataModels.put(userData);
user.userData.value = userData;
await user.userData.save();
}
});
return (user, tokenModel!.token);
}
}
// Future<void> addWordStatistics(
// UserModel user, AllWordsStatisticsDto wordStat) async {
// // final currentData =
// // user.userData?.decode(UserDataDto.fromJson) ?? UserDataDto();
// // updateUserData(
// // user,
// // currentData.copyWith.allWordsStatistics(
// // wordStat.merge(currentData.allWordsStatistics),
// // ),
// // );
// }
Future<void> addTestStatistics(
UserModel user,
TestStatisticsDto testStat,
) async {
UserDataModel currentData;
print('adding test stats');
if (user.userData.value == null) {
print('new user data');
currentData = UserDataModel();
await isar.writeTxn(() async {
final id = await isar.userDataModels.put(
currentData..user.value = user,
);
currentData = (await isar.userDataModels.get(id))!;
});
print('user data saved');
} else {
print('found user data');
currentData = user.userData.value!;
}
if (testStat.sessionToken == currentData.lastTestSessionToken) {
print('old session token');
return;
}
final testStatistics = await isar.txn(
() async =>
await currentData.testsStatistics
.filter()
.test((q) => q.idEqualTo(testStat.testId))
.findFirst() ??
TestStatisticsModel(),
);
print('got tests stat for ${testStat.testId}');
final allWords = currentData.words.mergeWithDto(testStat.words.words);
print(
'merged all words: ${allWords.length} (was ${currentData.words.length})');
return isar.writeTxn(() async {
try {
final test = await isar.testModels.get(testStat.testId);
// final testWords = updateWords(testStatistics.words, testStat.words.words);
final updatedTestStat = testStatistics.copyWith.attempts(
[
...testStatistics.attempts.takeLast(2),
TestAttempt(
words: testStat.words.words.map((e) => e.toModel()).toList(),
sessionToken: testStat.sessionToken,
),
],
)..test.value = test;
await isar.testStatisticsModels.put(updatedTestStat);
await updatedTestStat.test.save();
print('updated test stats saved');
// Calculate updated statistics
final studyDates = List<DateTime>.from(currentData.studyDates);
final today = DateTime.now();
final todayNormalized = DateTime(today.year, today.month, today.day);
// Add today's study date if not already present
if (!studyDates.any((date) =>
date.year == todayNormalized.year &&
date.month == todayNormalized.month &&
date.day == todayNormalized.day)) {
studyDates.add(todayNormalized);
}
// Calculate current streak based on study dates
final currentStreak = _statisticsCalculator.calculateStreak(studyDates);
// Calculate longest streak
final longestStreak = currentStreak > currentData.longestStreak
? currentStreak
: currentData.longestStreak;
// Calculate total study time (simplified - add time from this test)
final testDurationMinutes = 5; // Assume 5 minutes per test as default
final totalStudyTimeMinutes =
currentData.totalStudyTimeMinutes + testDurationMinutes;
// Calculate pack progress if we have pack info
final packProgress =
List<PackProgressDto>.from(currentData.packProgress);
// Update pack progress if test was for a specific pack
if (testStat.testId != null) {
// This would need pack information lookup - simplified for now
// In a real implementation, we'd update the specific pack's progress
}
// Check and unlock achievements with updated data
final tempUpdatedData = currentData.copyWith(
currentStreak: currentStreak,
longestStreak: longestStreak,
studyDates: studyDates,
totalStudyTimeMinutes: totalStudyTimeMinutes,
words: allWords,
);
// Check for newly unlocked achievements
await checkAndUpdateAchievements(user, tempUpdatedData);
final updatedData = currentData.copyWith(
lastTestSessionToken: testStat.sessionToken,
words: allWords,
currentStreak: currentStreak,
longestStreak: longestStreak,
studyDates: studyDates,
totalStudyTimeMinutes: totalStudyTimeMinutes,
lastTimeOnline: today,
)
..testsStatistics.add(updatedTestStat)
..user.value = user;
// Note: packProgress and achievements are handled separately
// since UserDataModel expects Model types, not DTO types
print('saving updated user data');
await isar.userDataModels.put(updatedData);
await updatedData.testsStatistics.save();
await updatedData.user.save();
print('user data saved');
// Track session activity
await _updateSessionFromTest(user, testStat, updatedData);
} catch (e, s) {
print('error while saving data ${e.toString()} ${s.toString()}');
log('error', error: e, stackTrace: s);
}
});
}
/// Check and update achievements for a user
Future<List<AchievementDto>> checkAndUpdateAchievements(
UserModel user,
UserDataModel userData,
) async {
try {
final newlyUnlockedAchievements =
await _achievementManager.checkAndUnlockAchievements(
user.id!,
userData,
);
if (newlyUnlockedAchievements.isNotEmpty) {
print(
'New achievements unlocked for user ${user.id}: ${newlyUnlockedAchievements.map((a) => a.title).join(', ')}');
}
return newlyUnlockedAchievements;
} catch (e, s) {
print('Error checking achievements for user ${user.id}: $e\n$s');
return [];
}
}
/// Update session tracking based on test completion
Future<void> _updateSessionFromTest(
UserModel user,
TestStatisticsDto testStat,
UserDataModel updatedData,
) async {
if (user.id == null) return;
try {
// Get or create session for this user
final sessionId = await _sessionTracker.getOrCreateSession(
user.id!,
testId: testStat.testId.toString(),
);
// Calculate test statistics
final wordsLearned = testStat.words.words.length;
final correctAnswers = testStat.words.words.fold<int>(
0,
(sum, word) => sum + word.correct.toInt(),
);
final totalAnswers = testStat.words.words.fold<int>(
0,
(sum, word) => sum + word.correct.toInt() + word.incorrect.toInt(),
);
final accuracy = totalAnswers > 0 ? correctAnswers / totalAnswers : 0.0;
// Update session progress
await _sessionTracker.updateSessionProgress(
sessionId,
wordsLearned: wordsLearned,
testsCompleted: 1,
accuracy: accuracy,
);
print(
'Updated session $sessionId: +$wordsLearned words, accuracy: ${accuracy.toStringAsFixed(2)}');
} catch (e, s) {
print('Error updating session tracking: $e\n$s');
// Don't fail the main operation if session tracking fails
}
}
Future<void> updateUserSettings(UserModel user, UserSettingsDto settings) {
return isar.writeTxn(
() => isar.userModels.put(
user.copyWith(userSettings: settings.encode()),
),
);
}
Future<bool> editUser(UserDto user) async {
if (user.id == null || user.id! < 0) {
throw Exception('Cant edit new user');
}
final existUser = await isar.userModels.get(user.id!);
if (existUser == null) {
throw Exception('User ${user.id} not found');
}
final dtoPackIds = user.packs.map((s) => int.tryParse(s)).whereNotNull();
final dtoPacks = dtoPackIds.isEmpty
? <CardPackModel>[]
: (await isar.cardPackModels.getAll(dtoPackIds.toList()))
.whereNotNull()
.toList();
isar.writeTxn(() async {
final updatedUser = existUser.copyWith(
name: user.name ?? existUser.name,
// subscription: user.subscription,
// userSettings: user.userSettingsDto?.encode() ?? existUser.userSettings,
// userData: user.userDataDto?.encode() ?? existUser.userData,
);
final id = await isar.userModels.put(updatedUser);
final packs = (await isar.userModels.get(id))!.packs;
await updatedUser.subscriptionModel.load();
final subscriptionModel = updatedUser.subscriptionModel.value;
print(
'Editing user ${user.id}, subscription = ${user.subscription} and hasModel = ${subscriptionModel != null}',
);
if (user.subscription == true) {
UserSubscriptionModel userSubscriptionModel;
if (subscriptionModel == null) {
print('Creating new sub model');
userSubscriptionModel = UserSubscriptionModel(
start: DateTime.now(),
finish: DateTime.now().add(Duration(days: 1)),
features: [
SubscriptionFeatureEnum.packs,
SubscriptionFeatureEnum.ads,
],
);
} else {
print('Updating sub model');
userSubscriptionModel = subscriptionModel.copyWith(
start: DateTime.now(),
finish: DateTime.now().add(Duration(days: 1)),
features: [
SubscriptionFeatureEnum.packs,
SubscriptionFeatureEnum.ads,
]);
}
updatedUser.subscriptionModel.value = userSubscriptionModel;
await isar.userSubscriptionModels.put(userSubscriptionModel);
await updatedUser.subscriptionModel.save();
} else if (subscriptionModel != null) {
final updatedModel = subscriptionModel.copyWith(
start: DateTime.now(),
finish: DateTime.now(),
features: [],
)..user.value = updatedUser;
await isar.userSubscriptionModels.put(updatedModel);
updatedUser.subscriptionModel.value = updatedModel;
await updatedUser.subscriptionModel.save();
}
await packs.reset();
await (packs..addAll(dtoPacks)).save();
});
return true;
}
Future<bool> deleteUser(String stringId) async {
final id = int.tryParse(stringId);
if (id != null) {
await isar.writeTxn(() async {
final tokens = await isar.tokenModels
.filter()
.userIdEqualTo(id)
.idProperty()
.findAll();
if (tokens.isNotEmpty) {
await isar.tokenModels.deleteAll(tokens);
}
await isar.testStatisticsModels
.filter()
.userData((q) => q.user((u) => u.idEqualTo(id)))
.deleteAll();
await isar.userDataModels
.filter()
.user((q) => q.idEqualTo(id))
.deleteAll();
return isar.userModels.delete(id);
});
return true;
}
return false;
}
}