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) {
const errorMessage = error instanceof CardsApiError
const errorMessage = isCardsApiError(error)
? error.message
: formatApiError(error)
return (

View file

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

View file

@ -73,9 +73,6 @@ extension GetItInjectableX on _i174.GetIt {
gh.lazySingleton<_i240.TelegramAuthCodeService>(
() => _i240.TelegramAuthCodeService(),
);
gh.lazySingleton<_i1029.StatisticsCalculator>(
() => _i1029.StatisticsCalculator(),
);
gh.lazySingleton<_i377.SubscriptionManager>(
() => _i377.SubscriptionManager(gh<_i1072.AppDatabase>()),
);
@ -97,6 +94,9 @@ extension GetItInjectableX on _i174.GetIt {
gh.lazySingleton<_i71.SessionTracker>(
() => _i71.SessionTracker(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i1029.StatisticsCalculator>(
() => _i1029.StatisticsCalculator(gh<_i1072.AppDatabase>()),
);
gh.lazySingleton<_i909.WordStatisticsManager>(
() => _i909.WordStatisticsManager(gh<_i1072.AppDatabase>()),
);
@ -125,15 +125,6 @@ extension GetItInjectableX on _i174.GetIt {
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>(
() => _i280.UserManager(
gh<_i1072.AppDatabase>(),
@ -141,6 +132,23 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i71.SessionTracker>(),
gh<_i1029.StatisticsCalculator>(),
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>(
@ -194,13 +202,6 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i240.TelegramAuthCodeService>(),
),
);
gh.lazySingleton<_i483.AdminAuthApiV2>(
() => _i483.AdminAuthApiV2(
gh<_i240.TelegramAuthCodeService>(),
gh<_i280.UserManager>(),
gh<_i108.JwtService>(),
),
);
gh.lazySingleton<_i433.PackDtoConverter>(
() => _i433.PackDtoConverter(
gh<_i908.ProductsPriceResolver>(),

View file

@ -56,6 +56,9 @@ class UsersApiV2 {
/// GET /api/v2/users/me
/// Returns current authenticated user.
///
/// Рассчитывает packProgress, studyDates, categoryMinutes на лету
/// из WordStatistics и StudySessions (вместо удаленных полей UserDatas)
@Route.get('/users/me')
Future<Response> getCurrentUser(Request request) async {
final user = request.user;
@ -63,7 +66,37 @@ class UsersApiV2 {
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());
}
@ -245,14 +278,14 @@ class UsersApiV2 {
return _json({'error': 'user_data_not_found'}, statusCode: 404);
}
// Use UserDataModel extension to convert to DTO
// TODO: Need to create proper conversion from Drift UserData to UserDataDto
// For now, return basic structure
// Рассчитать studyDates на лету из StudySessions
final studyDates = await _statisticsCalculator.calculateStudyDates(user.id!);
return _json({
'totalStudyTimeMinutes': userData.totalStudyTimeMinutes,
'currentStreak': userData.currentStreak,
'longestStreak': userData.longestStreak,
'studyDates': (userData.studyDates ?? []).map((d) => d.toIso8601String()).toList(),
'studyDates': studyDates.map((d) => d.toIso8601String()).toList(),
});
} catch (e) {
return _json(
@ -280,11 +313,14 @@ class UsersApiV2 {
return _json([]);
}
final packProgress = packId != null
? userData.packProgress.where((p) => p.packId == packId).toList()
: userData.packProgress;
// Рассчитать packProgress на лету из WordStatistics
final allPackProgress = await _statisticsCalculator.calculateAllPackProgress(user.id!);
final dtos = packProgress.map((p) => p.toDto()).toList();
final packProgress = packId != null
? allPackProgress.where((p) => p.packId == packId).toList()
: allPackProgress;
final dtos = packProgress;
return _json(dtos.map((dto) => dto.toJson()).toList());
} catch (e) {
return _json(
@ -444,8 +480,9 @@ class UsersApiV2 {
});
}
final timelineData = _statisticsCalculator.getTimelineStatistics(
userData,
// Рассчитать timeline statistics (теперь принимает userId вместо UserDataModel)
final timelineData = await _statisticsCalculator.getTimelineStatistics(
user.id!,
period: period,
from: fromDate,
to: toDate,
@ -498,24 +535,20 @@ class UsersApiV2 {
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
final now = DateTime.now();
final totalStudyTime =
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 longestStreak = max(userData.longestStreak, currentStreak);
// Update user data
// Update user data (без studyDates - это поле удалено, рассчитывается на лету)
await _db.userDao.updateUserDataPartial(
UserDatasCompanion(
userId: drift.Value(user.id!),
studyDates: drift.Value(studyDates),
totalStudyTimeMinutes: drift.Value(totalStudyTime),
currentStreak: drift.Value(currentStreak),
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();
}
/// Найти карточки по 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 {
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_backend/mnemo_cards_common_backend.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
@lazySingleton
@ -139,10 +138,10 @@ class StatisticsCalculator {
if (session.packId == null) continue;
// Получить категорию пака
// Примечание: если в CardPacks нет поля category, используем 'unknown'
// Примечание: в CardPacks нет поля category, используем 'unknown'
// В будущем можно добавить поле category в CardPacks или использовать другую логику
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;
@ -296,18 +295,42 @@ class StatisticsCalculator {
}
/// Get timeline statistics for a specific period
Map<String, dynamic> getTimelineStatistics(
UserDataModel data, {
///
/// Примечание: теперь принимает рассчитанные данные вместо UserDataModel
/// для работы с новой структурой (без удаленных полей)
Future<Map<String, dynamic>> getTimelineStatistics(
String userId, {
required String period,
DateTime? from,
DateTime? to,
}) {
}) async {
final now = DateTime.now();
final startDate = from ?? _getStartDateForPeriod(period, 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
final periodDailyTime = <DateTime, int>{};
@ -334,7 +357,7 @@ class StatisticsCalculator {
periodDailyTime.values.fold<int>(0, (sum, time) => sum + time);
final averageDailyMinutes = activeDays > 0 ? totalMinutes / activeDays : 0;
// Calculate streak in period
// Calculate streak in period (из рассчитанных studyDates)
final periodStreak = calculateStreak(periodStudyDates);
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';
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(
allWordsStatistics: AllWordsStatisticsDto(
allWordsStatistics: calculatedWordsStatistics ?? AllWordsStatisticsDto(
words: words.map((model) => model.toDto()).toList(),
),
allTestsStatistics: AllTestsStatisticsDto(
@ -20,11 +29,11 @@ extension UserDataModelExtension on UserDataModel {
totalStudyTimeMinutes: totalStudyTimeMinutes,
currentStreak: currentStreak,
longestStreak: longestStreak,
packProgress: {
for (final progress in packProgress) progress.packId: progress.toDto()
},
studyDates: List<DateTime>.from(studyDates),
categoryMinutes: Map<String, int>.from(categoryMinutes),
packProgress: calculatedPackProgress != null
? {for (final progress in calculatedPackProgress) progress.packId: progress}
: {for (final progress in packProgress) progress.packId: progress.toDto()},
studyDates: calculatedStudyDates ?? List<DateTime>.from(studyDates),
categoryMinutes: calculatedCategoryMinutes ?? Map<String, int>.from(categoryMinutes),
achievements: achievements.map((model) => model.toDto()).toList(),
);
}

View file

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

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';
extension UserModelExtension on UserModel {
/// Конвертация в DTO
///
/// Примечание: для расчета packProgress, studyDates, categoryMinutes на лету
/// используйте toDtoWithCalculatedData()
Future<UserDto> toDto() async {
final activeSubscription = subscriptionModel != null && subscriptionModel!.isActive;
return UserDto(
@ -16,4 +20,32 @@ extension UserModelExtension on UserModel {
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,
),
);
}
}