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
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:
parent
e4e6a259c7
commit
4f29ccd3c8
9 changed files with 185 additions and 72 deletions
|
|
@ -226,7 +226,7 @@ export default function CardsPage() {
|
|||
}
|
||||
|
||||
if (error) {
|
||||
const errorMessage = error instanceof CardsApiError
|
||||
const errorMessage = isCardsApiError(error)
|
||||
? error.message
|
||||
: formatApiError(error)
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ export default function PacksPage() {
|
|||
}
|
||||
|
||||
if (error) {
|
||||
const errorMessage = error instanceof PacksApiError
|
||||
const errorMessage = isPacksApiError(error)
|
||||
? error.message
|
||||
: formatApiError(error)
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
}
|
||||
|
||||
// Рассчитать packProgress на лету из WordStatistics
|
||||
final allPackProgress = await _statisticsCalculator.calculateAllPackProgress(user.id!);
|
||||
|
||||
final packProgress = packId != null
|
||||
? userData.packProgress.where((p) => p.packId == packId).toList()
|
||||
: userData.packProgress;
|
||||
? allPackProgress.where((p) => p.packId == packId).toList()
|
||||
: allPackProgress;
|
||||
|
||||
final dtos = packProgress.map((p) => p.toDto()).toList();
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue