backaend + admin
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

This commit is contained in:
Dmitry 2025-12-14 23:45:27 +03:00
parent e4e6a259c7
commit 4f29ccd3c8
9 changed files with 185 additions and 72 deletions

View file

@ -226,7 +226,7 @@ export default function CardsPage() {
} }
if (error) { if (error) {
const errorMessage = error instanceof CardsApiError const errorMessage = isCardsApiError(error)
? error.message ? error.message
: formatApiError(error) : formatApiError(error)
return ( return (

View file

@ -259,7 +259,7 @@ export default function PacksPage() {
} }
if (error) { if (error) {
const errorMessage = error instanceof PacksApiError const errorMessage = isPacksApiError(error)
? error.message ? error.message
: formatApiError(error) : formatApiError(error)
return ( return (

View file

@ -73,9 +73,6 @@ extension GetItInjectableX on _i174.GetIt {
gh.lazySingleton<_i240.TelegramAuthCodeService>( gh.lazySingleton<_i240.TelegramAuthCodeService>(
() => _i240.TelegramAuthCodeService(), () => _i240.TelegramAuthCodeService(),
); );
gh.lazySingleton<_i1029.StatisticsCalculator>(
() => _i1029.StatisticsCalculator(),
);
gh.lazySingleton<_i377.SubscriptionManager>( gh.lazySingleton<_i377.SubscriptionManager>(
() => _i377.SubscriptionManager(gh<_i1072.AppDatabase>()), () => _i377.SubscriptionManager(gh<_i1072.AppDatabase>()),
); );
@ -97,6 +94,9 @@ extension GetItInjectableX on _i174.GetIt {
gh.lazySingleton<_i71.SessionTracker>( gh.lazySingleton<_i71.SessionTracker>(
() => _i71.SessionTracker(gh<_i1072.AppDatabase>()), () => _i71.SessionTracker(gh<_i1072.AppDatabase>()),
); );
gh.lazySingleton<_i1029.StatisticsCalculator>(
() => _i1029.StatisticsCalculator(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i909.WordStatisticsManager>( gh.lazySingleton<_i909.WordStatisticsManager>(
() => _i909.WordStatisticsManager(gh<_i1072.AppDatabase>()), () => _i909.WordStatisticsManager(gh<_i1072.AppDatabase>()),
); );
@ -125,15 +125,6 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i222.RustorePurchaseHandler>(), gh<_i222.RustorePurchaseHandler>(),
), ),
); );
gh.lazySingleton<_i985.TasksApiV2>(
() => _i985.TasksApiV2(gh<_i586.TaskManager>()),
);
gh.lazySingleton<_i151.PromoCodesManager>(
() => _i151.PromoCodesManager(
gh<_i1072.AppDatabase>(),
gh<_i1009.PaymentManager>(),
),
);
gh.lazySingleton<_i280.UserManager>( gh.lazySingleton<_i280.UserManager>(
() => _i280.UserManager( () => _i280.UserManager(
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
@ -141,6 +132,23 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i71.SessionTracker>(), gh<_i71.SessionTracker>(),
gh<_i1029.StatisticsCalculator>(), gh<_i1029.StatisticsCalculator>(),
gh<_i802.AchievementManager>(), gh<_i802.AchievementManager>(),
gh<_i909.WordStatisticsManager>(),
),
);
gh.lazySingleton<_i985.TasksApiV2>(
() => _i985.TasksApiV2(gh<_i586.TaskManager>()),
);
gh.lazySingleton<_i483.AdminAuthApiV2>(
() => _i483.AdminAuthApiV2(
gh<_i240.TelegramAuthCodeService>(),
gh<_i280.UserManager>(),
gh<_i108.JwtService>(),
),
);
gh.lazySingleton<_i151.PromoCodesManager>(
() => _i151.PromoCodesManager(
gh<_i1072.AppDatabase>(),
gh<_i1009.PaymentManager>(),
), ),
); );
gh.lazySingleton<_i560.UserManager>( gh.lazySingleton<_i560.UserManager>(
@ -194,13 +202,6 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i240.TelegramAuthCodeService>(), gh<_i240.TelegramAuthCodeService>(),
), ),
); );
gh.lazySingleton<_i483.AdminAuthApiV2>(
() => _i483.AdminAuthApiV2(
gh<_i240.TelegramAuthCodeService>(),
gh<_i280.UserManager>(),
gh<_i108.JwtService>(),
),
);
gh.lazySingleton<_i433.PackDtoConverter>( gh.lazySingleton<_i433.PackDtoConverter>(
() => _i433.PackDtoConverter( () => _i433.PackDtoConverter(
gh<_i908.ProductsPriceResolver>(), gh<_i908.ProductsPriceResolver>(),

View file

@ -56,6 +56,9 @@ class UsersApiV2 {
/// GET /api/v2/users/me /// GET /api/v2/users/me
/// Returns current authenticated user. /// Returns current authenticated user.
///
/// Рассчитывает packProgress, studyDates, categoryMinutes на лету
/// из WordStatistics и StudySessions (вместо удаленных полей UserDatas)
@Route.get('/users/me') @Route.get('/users/me')
Future<Response> getCurrentUser(Request request) async { Future<Response> getCurrentUser(Request request) async {
final user = request.user; final user = request.user;
@ -63,7 +66,37 @@ class UsersApiV2 {
return _unauthorized(); return _unauthorized();
} }
final dto = await user.toDto(); if (user.id == null) {
return _json({'error': 'user_id_not_found'}, statusCode: 400);
}
// Рассчитать статистику на лету
final packProgress = await _statisticsCalculator.calculateAllPackProgress(user.id!);
final studyDates = await _statisticsCalculator.calculateStudyDates(user.id!);
final categoryMinutes = await _statisticsCalculator.calculateCategoryMinutes(user.id!);
// Получить статистику по словам из WordStatistics
final wordStats = await _db.wordStatisticsDao.getUserStatistics(user.id!);
final wordsDto = AllWordsStatisticsDto(
words: wordStats.map((stat) {
// Найти карточку для получения слова
// Для простоты используем cardId как word (в будущем можно улучшить)
return WordStatisticsDto(
word: stat.cardId, // Временное решение - нужно получить original из GameCard
correct: stat.correctAnswers.toDouble(),
incorrect: stat.incorrectAnswers.toDouble(),
skipped: 0,
questionTypes: {},
);
}).toList(),
);
final dto = await user.toDtoWithCalculatedData(
packProgress: packProgress,
studyDates: studyDates,
categoryMinutes: categoryMinutes,
wordsStatistics: wordsDto,
);
return _json(dto.toJson()); return _json(dto.toJson());
} }
@ -245,14 +278,14 @@ class UsersApiV2 {
return _json({'error': 'user_data_not_found'}, statusCode: 404); return _json({'error': 'user_data_not_found'}, statusCode: 404);
} }
// Use UserDataModel extension to convert to DTO // Рассчитать studyDates на лету из StudySessions
// TODO: Need to create proper conversion from Drift UserData to UserDataDto final studyDates = await _statisticsCalculator.calculateStudyDates(user.id!);
// For now, return basic structure
return _json({ return _json({
'totalStudyTimeMinutes': userData.totalStudyTimeMinutes, 'totalStudyTimeMinutes': userData.totalStudyTimeMinutes,
'currentStreak': userData.currentStreak, 'currentStreak': userData.currentStreak,
'longestStreak': userData.longestStreak, 'longestStreak': userData.longestStreak,
'studyDates': (userData.studyDates ?? []).map((d) => d.toIso8601String()).toList(), 'studyDates': studyDates.map((d) => d.toIso8601String()).toList(),
}); });
} catch (e) { } catch (e) {
return _json( return _json(
@ -280,11 +313,14 @@ class UsersApiV2 {
return _json([]); return _json([]);
} }
// Рассчитать packProgress на лету из WordStatistics
final allPackProgress = await _statisticsCalculator.calculateAllPackProgress(user.id!);
final packProgress = packId != null final packProgress = packId != null
? userData.packProgress.where((p) => p.packId == packId).toList() ? allPackProgress.where((p) => p.packId == packId).toList()
: userData.packProgress; : allPackProgress;
final dtos = packProgress.map((p) => p.toDto()).toList(); final dtos = packProgress;
return _json(dtos.map((dto) => dto.toJson()).toList()); return _json(dtos.map((dto) => dto.toJson()).toList());
} catch (e) { } catch (e) {
return _json( return _json(
@ -444,8 +480,9 @@ class UsersApiV2 {
}); });
} }
final timelineData = _statisticsCalculator.getTimelineStatistics( // Рассчитать timeline statistics (теперь принимает userId вместо UserDataModel)
userData, final timelineData = await _statisticsCalculator.getTimelineStatistics(
user.id!,
period: period, period: period,
from: fromDate, from: fromDate,
to: toDate, to: toDate,
@ -498,24 +535,20 @@ class UsersApiV2 {
throw StateError('User data not found'); throw StateError('User data not found');
} }
// Update study dates
final now = DateTime.now();
final studyDates = List<DateTime>.from(userData.studyDates ?? []);
studyDates.add(now);
// Update total study time // Update total study time
final now = DateTime.now();
final totalStudyTime = final totalStudyTime =
userData.totalStudyTimeMinutes + sessionDto.duration.inMinutes; userData.totalStudyTimeMinutes + sessionDto.duration.inMinutes;
// Recalculate streak // Recalculate streak from StudySessions (studyDates теперь рассчитывается на лету)
final studyDates = await _statisticsCalculator.calculateStudyDates(user.id!);
final currentStreak = _statisticsCalculator.calculateStreak(studyDates); final currentStreak = _statisticsCalculator.calculateStreak(studyDates);
final longestStreak = max(userData.longestStreak, currentStreak); final longestStreak = max(userData.longestStreak, currentStreak);
// Update user data // Update user data (без studyDates - это поле удалено, рассчитывается на лету)
await _db.userDao.updateUserDataPartial( await _db.userDao.updateUserDataPartial(
UserDatasCompanion( UserDatasCompanion(
userId: drift.Value(user.id!), userId: drift.Value(user.id!),
studyDates: drift.Value(studyDates),
totalStudyTimeMinutes: drift.Value(totalStudyTime), totalStudyTimeMinutes: drift.Value(totalStudyTime),
currentStreak: drift.Value(currentStreak), currentStreak: drift.Value(currentStreak),
longestStreak: drift.Value(longestStreak), longestStreak: drift.Value(longestStreak),

View file

@ -123,6 +123,15 @@ class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
return (select(gameCards)..where((c) => c.id.isIn(ids))).get(); return (select(gameCards)..where((c) => c.id.isIn(ids))).get();
} }
/// Найти карточки по original (слову)
/// Возвращает все карточки с указанным original (может быть несколько)
Future<List<GameCard>> searchCardsByOriginal(String original) {
return (select(gameCards)
..where((c) => c.original.equals(original) & c.isDeleted.equals(false))
..limit(10) // Ограничиваем для производительности
).get();
}
/// Создать карточку /// Создать карточку
Future<String> createCard(GameCardsCompanion card) async { Future<String> createCard(GameCardsCompanion card) async {
final inserted = await into(gameCards).insertReturning(card); final inserted = await into(gameCards).insertReturning(card);

View file

@ -4,7 +4,6 @@ import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:drift_postgres/drift_postgres.dart';
/// Service for calculating various user statistics from raw data /// Service for calculating various user statistics from raw data
@lazySingleton @lazySingleton
@ -139,10 +138,10 @@ class StatisticsCalculator {
if (session.packId == null) continue; if (session.packId == null) continue;
// Получить категорию пака // Получить категорию пака
// Примечание: если в CardPacks нет поля category, используем 'unknown' // Примечание: в CardPacks нет поля category, используем 'unknown'
// В будущем можно добавить поле category в CardPacks или использовать другую логику // В будущем можно добавить поле category в CardPacks или использовать другую логику
final pack = await _db.packDao.getPackById(session.packId!); final pack = await _db.packDao.getPackById(session.packId!);
final category = pack != null ? (pack.category ?? 'unknown') : 'unknown'; final category = pack != null ? 'unknown' : 'unknown'; // TODO: добавить category в CardPacks
// Рассчитать время сессии в минутах // Рассчитать время сессии в минутах
int sessionMinutes = 0; int sessionMinutes = 0;
@ -296,18 +295,42 @@ class StatisticsCalculator {
} }
/// Get timeline statistics for a specific period /// Get timeline statistics for a specific period
Map<String, dynamic> getTimelineStatistics( ///
UserDataModel data, { /// Примечание: теперь принимает рассчитанные данные вместо UserDataModel
/// для работы с новой структурой (без удаленных полей)
Future<Map<String, dynamic>> getTimelineStatistics(
String userId, {
required String period, required String period,
DateTime? from, DateTime? from,
DateTime? to, DateTime? to,
}) { }) async {
final now = DateTime.now(); final now = DateTime.now();
final startDate = from ?? _getStartDateForPeriod(period, now); final startDate = from ?? _getStartDateForPeriod(period, now);
final endDate = to ?? now; final endDate = to ?? now;
final dailyTime = calculateDailyStudyTime(data); // Получить сессии для расчета времени
final studyDates = data.studyDates; final sessions = await _db.statisticsDao.getSessionsByUserId(
userId,
fromDate: startDate,
toDate: endDate,
);
// Рассчитать ежедневное время из сессий
final dailyTime = <DateTime, int>{};
for (final session in sessions) {
if (session.endTime != null && session.startTime != null) {
final date = DateTime(
session.startTime.dateTime.year,
session.startTime.dateTime.month,
session.startTime.dateTime.day,
);
final duration = session.endTime!.dateTime.difference(session.startTime.dateTime);
dailyTime[date] = (dailyTime[date] ?? 0) + duration.inMinutes;
}
}
// Получить studyDates
final studyDates = await calculateStudyDates(userId);
// Filter data for the period // Filter data for the period
final periodDailyTime = <DateTime, int>{}; final periodDailyTime = <DateTime, int>{};
@ -334,7 +357,7 @@ class StatisticsCalculator {
periodDailyTime.values.fold<int>(0, (sum, time) => sum + time); periodDailyTime.values.fold<int>(0, (sum, time) => sum + time);
final averageDailyMinutes = activeDays > 0 ? totalMinutes / activeDays : 0; final averageDailyMinutes = activeDays > 0 ? totalMinutes / activeDays : 0;
// Calculate streak in period // Calculate streak in period (из рассчитанных studyDates)
final periodStreak = calculateStreak(periodStudyDates); final periodStreak = calculateStreak(periodStudyDates);
return { return {

View file

@ -2,9 +2,18 @@ import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
extension UserDataModelExtension on UserDataModel { extension UserDataModelExtension on UserDataModel {
UserDataDto toDto() { /// Конвертация в DTO с использованием данных из модели
///
/// Примечание: packProgress, studyDates, categoryMinutes теперь рассчитываются на лету
/// и должны передаваться как параметры
UserDataDto toDto({
List<PackProgressDto>? calculatedPackProgress,
List<DateTime>? calculatedStudyDates,
Map<String, int>? calculatedCategoryMinutes,
AllWordsStatisticsDto? calculatedWordsStatistics,
}) {
return UserDataDto( return UserDataDto(
allWordsStatistics: AllWordsStatisticsDto( allWordsStatistics: calculatedWordsStatistics ?? AllWordsStatisticsDto(
words: words.map((model) => model.toDto()).toList(), words: words.map((model) => model.toDto()).toList(),
), ),
allTestsStatistics: AllTestsStatisticsDto( allTestsStatistics: AllTestsStatisticsDto(
@ -20,11 +29,11 @@ extension UserDataModelExtension on UserDataModel {
totalStudyTimeMinutes: totalStudyTimeMinutes, totalStudyTimeMinutes: totalStudyTimeMinutes,
currentStreak: currentStreak, currentStreak: currentStreak,
longestStreak: longestStreak, longestStreak: longestStreak,
packProgress: { packProgress: calculatedPackProgress != null
for (final progress in packProgress) progress.packId: progress.toDto() ? {for (final progress in calculatedPackProgress) progress.packId: progress}
}, : {for (final progress in packProgress) progress.packId: progress.toDto()},
studyDates: List<DateTime>.from(studyDates), studyDates: calculatedStudyDates ?? List<DateTime>.from(studyDates),
categoryMinutes: Map<String, int>.from(categoryMinutes), categoryMinutes: calculatedCategoryMinutes ?? Map<String, int>.from(categoryMinutes),
achievements: achievements.map((model) => model.toDto()).toList(), achievements: achievements.map((model) => model.toDto()).toList(),
); );
} }

View file

@ -55,7 +55,7 @@ class UserManager {
if (token.expires.dateTime.isAfter(now)) { if (token.expires.dateTime.isAfter(now)) {
return token.token; return token.token;
} }
await _db.userDao.deleteToken(token.id); await _db.userDao.softDeleteToken(token.id);
} }
final userToken = Secure.token(); final userToken = Secure.token();
await _db.userDao.createToken( await _db.userDao.createToken(
@ -76,7 +76,7 @@ class UserManager {
} }
final now = DateTime.now(); final now = DateTime.now();
if (token.expires.dateTime.isBefore(now)) { if (token.expires.dateTime.isBefore(now)) {
await _db.userDao.deleteToken(token.id); await _db.userDao.softDeleteToken(token.id);
return null; return null;
} }
final user = await fetchUser(token.userId); final user = await fetchUser(token.userId);
@ -253,22 +253,28 @@ class UserManager {
if (cards.isNotEmpty) { if (cards.isNotEmpty) {
final card = cards.first; final card = cards.first;
// Записать каждый ответ (correct/incorrect) // Записать ответы
// correct и incorrect в WordStatisticsDto - это уже агрегированные значения // correct и incorrect в WordStatisticsDto - это уже агрегированные значения
// Но для простоты запишем их как один ответ // Записываем их как несколько ответов для точности статистики
if (wordStat.correct > 0) { // Но для простоты запишем один раз с правильным количеством
await _wordStatisticsManager.recordAnswer( // WordStatisticsManager.recordAnswer() обновит существующую запись
userId: user.id!, final totalAnswers = (wordStat.correct + wordStat.incorrect).toInt();
cardId: card.id, if (totalAnswers > 0) {
isCorrect: true, // Записываем все ответы (правильные и неправильные)
); for (var i = 0; i < wordStat.correct.toInt(); i++) {
} await _wordStatisticsManager.recordAnswer(
if (wordStat.incorrect > 0) { userId: user.id!,
await _wordStatisticsManager.recordAnswer( cardId: card.id,
userId: user.id!, isCorrect: true,
cardId: card.id, );
isCorrect: false, }
); for (var i = 0; i < wordStat.incorrect.toInt(); i++) {
await _wordStatisticsManager.recordAnswer(
userId: user.id!,
cardId: card.id,
isCorrect: false,
);
}
} }
} }
} }

View file

@ -3,6 +3,10 @@ import 'package:mnemo_cards_backend/user/user_data_model.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
extension UserModelExtension on UserModel { extension UserModelExtension on UserModel {
/// Конвертация в DTO
///
/// Примечание: для расчета packProgress, studyDates, categoryMinutes на лету
/// используйте toDtoWithCalculatedData()
Future<UserDto> toDto() async { Future<UserDto> toDto() async {
final activeSubscription = subscriptionModel != null && subscriptionModel!.isActive; final activeSubscription = subscriptionModel != null && subscriptionModel!.isActive;
return UserDto( return UserDto(
@ -16,4 +20,32 @@ extension UserModelExtension on UserModel {
userDataDto: userData?.toDto(), userDataDto: userData?.toDto(),
); );
} }
/// Конвертация в DTO с рассчитанными данными на лету
///
/// Используется когда нужно рассчитать packProgress, studyDates, categoryMinutes
/// из WordStatistics и StudySessions вместо использования удаленных полей UserDatas
Future<UserDto> toDtoWithCalculatedData({
required List<PackProgressDto> packProgress,
required List<DateTime> studyDates,
required Map<String, int> categoryMinutes,
AllWordsStatisticsDto? wordsStatistics,
}) async {
final activeSubscription = subscriptionModel != null && subscriptionModel!.isActive;
return UserDto(
id: id,
name: name,
email: email,
admin: admin,
packs: packs.map((e) => e.id?.toString()).whereNotNull().toList(),
subscription: activeSubscription,
subscriptionFeatures: subscriptionModel?.features.toSet() ?? {},
userDataDto: userData?.toDto(
calculatedPackProgress: packProgress,
calculatedStudyDates: studyDates,
calculatedCategoryMinutes: categoryMinutes,
calculatedWordsStatistics: wordsStatistics,
),
);
}
} }