err
Some checks are pending
Backend CI / test (push) Waiting to run
Backend 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

This commit is contained in:
Dmitry 2025-12-11 22:00:11 +03:00
parent 75048f4f33
commit a3a5a55fdc
20 changed files with 123 additions and 90 deletions

View file

@ -57,8 +57,9 @@ ENV PORT=3000 \
EXPOSE 3000 EXPOSE 3000
# Healthcheck - проверка доступности сервера # Healthcheck - проверка доступности сервера
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=5 \ # Increased start-period to 30s to allow server initialization (Isar, cron jobs, etc.)
CMD sh -c 'curl -sf http://127.0.0.1:${PORT:-3000}/health || exit 1' HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=3 \
CMD curl -f http://127.0.0.1:${PORT:-3000}/health || exit 1
# Start server - все настройки читаются из environment variables # Start server - все настройки читаются из environment variables
CMD ["/app/server"] CMD ["/app/server"]

View file

@ -45,7 +45,8 @@ class MnemoShelf {
final port = int.tryParse(portArg ?? '') ?? 3000; final port = int.tryParse(portArg ?? '') ?? 3000;
final certs = Platform.environment['CERTS_PATH']; // null = HTTP without SSL final certs = Platform.environment['CERTS_PATH']; // null = HTTP without SSL
final dualMode = Platform.environment['DUAL_MODE'] == 'true' || final dualMode = Platform.environment['DUAL_MODE'] == 'true' ||
Platform.environment['DUAL_MODE'] == '1'; // Запуск HTTP и HTTPS одновременно Platform.environment['DUAL_MODE'] ==
'1'; // Запуск HTTP и HTTPS одновременно
// V2 APIs (new RESTful API with OAuth2/JWT) // V2 APIs (new RESTful API with OAuth2/JWT)
final v2Routers = [ final v2Routers = [

View file

@ -67,24 +67,32 @@ class AdminAnalyticsApiV2 {
final paymentCount = await backend_main.isar.paymentModels.count(); final paymentCount = await backend_main.isar.paymentModels.count();
// Get recent users (last 10) // Get recent users (last 10)
final allUsers = await backend_main.isar.txn(() async => backend_main.isar.userModels.where().findAll()); final allUsers = await backend_main.isar
.txn(() async => backend_main.isar.userModels.where().findAll());
final sortedUsers = allUsers final sortedUsers = allUsers
..sort((a, b) => (b.id ?? 0).compareTo(a.id ?? 0)); ..sort((a, b) => (b.id ?? 0).compareTo(a.id ?? 0));
final recentUsers = sortedUsers.take(10).map((u) => { final recentUsers = sortedUsers
.take(10)
.map((u) => {
'id': u.id, 'id': u.id,
'name': u.name, 'name': u.name,
'email': u.email, 'email': u.email,
'createdAt': DateTime(1999).toIso8601String(), 'createdAt': DateTime(1999).toIso8601String(),
}).toList(); })
.toList();
// Get top packs by user count (mock data for now) // Get top packs by user count (mock data for now)
final allPacks = await backend_main.isar.txn(() async => backend_main.isar.cardPackModels.where().findAll()); final allPacks = await backend_main.isar
final topPacks = allPacks.take(5).map((p) => { .txn(() async => backend_main.isar.cardPackModels.where().findAll());
final topPacks = allPacks
.take(5)
.map((p) => {
'id': p.id, 'id': p.id,
'title': p.title, 'title': p.title,
'cards': p.cards.length, 'cards': p.cards.length,
'enabled': p.enabled, 'enabled': p.enabled,
}).toList(); })
.toList();
return _json({ return _json({
'stats': { 'stats': {
@ -126,7 +134,8 @@ class AdminAnalyticsApiV2 {
for (int i = 29; i >= 0; i--) { for (int i = 29; i >= 0; i--) {
final date = now.subtract(Duration(days: i)); final date = now.subtract(Duration(days: i));
final registrations = (i % 7) + 1; // Mock data: 1-7 registrations per day final registrations =
(i % 7) + 1; // Mock data: 1-7 registrations per day
chartData.add({ chartData.add({
'date': date.toIso8601String().split('T')[0], 'date': date.toIso8601String().split('T')[0],

View file

@ -146,7 +146,8 @@ Use this code to access the admin panel at admin.mnemo-cards.online
// Get user from database // Get user from database
// Note: UserModel doesn't have telegramUserId field, so we need to find user differently // Note: UserModel doesn't have telegramUserId field, so we need to find user differently
// For now, we'll look for admin users (assuming admin field exists) // For now, we'll look for admin users (assuming admin field exists)
final allUsers = await backend_main.isar.txn(() async => backend_main.isar.userModels.where().findAll()); final allUsers = await backend_main.isar
.txn(() async => backend_main.isar.userModels.where().findAll());
final user = allUsers.where((u) => u.admin).firstOrNull; final user = allUsers.where((u) => u.admin).firstOrNull;
if (user == null) { if (user == null) {

View file

@ -94,7 +94,8 @@ class AdminCardsApiV2 {
} }
// Get all cards from database // Get all cards from database
final allCards = await backend_main.isar.txn(() async => backend_main.isar.gameCardModels.where().findAll()); final allCards = await backend_main.isar
.txn(() async => backend_main.isar.gameCardModels.where().findAll());
// Apply search filter if provided // Apply search filter if provided
List<GameCardModel> filteredCards = allCards; List<GameCardModel> filteredCards = allCards;
@ -104,8 +105,10 @@ class AdminCardsApiV2 {
return card.original.toLowerCase().contains(searchLower) || return card.original.toLowerCase().contains(searchLower) ||
card.translation.toLowerCase().contains(searchLower) || card.translation.toLowerCase().contains(searchLower) ||
card.mnemo.toLowerCase().contains(searchLower) || card.mnemo.toLowerCase().contains(searchLower) ||
(card.transcription?.toLowerCase().contains(searchLower) ?? false) || (card.transcription?.toLowerCase().contains(searchLower) ??
(card.transcriptionMnemo?.toLowerCase().contains(searchLower) ?? false); false) ||
(card.transcriptionMnemo?.toLowerCase().contains(searchLower) ??
false);
}).toList(); }).toList();
} }
@ -184,7 +187,8 @@ class AdminCardsApiV2 {
late final GameCardDto cardDto; late final GameCardDto cardDto;
try { try {
cardDto = GameCardDto.fromJson(jsonDecode(body) as Map<String, dynamic>); cardDto =
GameCardDto.fromJson(jsonDecode(body) as Map<String, dynamic>);
} catch (_) { } catch (_) {
return _json( return _json(
{ {

View file

@ -98,8 +98,10 @@ class AdminPacksApiV2 {
} }
// Get all packs (admin can see disabled packs) // Get all packs (admin can see disabled packs)
final allPacks = await backend_main.isar.txn(() async => backend_main.isar.cardPackModels.where().findAll()); final allPacks = await backend_main.isar
List<CardPackModel> filteredPacks = showDisabled ? allPacks : allPacks.where((p) => p.enabled).toList(); .txn(() async => backend_main.isar.cardPackModels.where().findAll());
List<CardPackModel> filteredPacks =
showDisabled ? allPacks : allPacks.where((p) => p.enabled).toList();
if (search != null && search.isNotEmpty) { if (search != null && search.isNotEmpty) {
final searchLower = search.toLowerCase(); final searchLower = search.toLowerCase();
filteredPacks = allPacks.where((pack) { filteredPacks = allPacks.where((pack) {
@ -119,11 +121,8 @@ class AdminPacksApiV2 {
final paginatedPacks = filteredPacks.skip(offset).take(limit).toList(); final paginatedPacks = filteredPacks.skip(offset).take(limit).toList();
// Convert to preview DTOs // Convert to preview DTOs
final packDtos = await Future.wait( final packDtos = await Future.wait(paginatedPacks.map((pack) async =>
paginatedPacks.map((pack) async => await _packDtoConverter.toCardPackPreviewDto(pack, null)));
await _packDtoConverter.toCardPackPreviewDto(pack, null)
)
);
return _json({ return _json({
'items': packDtos.map((p) => p.toJson()).toList(), 'items': packDtos.map((p) => p.toJson()).toList(),
@ -194,7 +193,8 @@ class AdminPacksApiV2 {
late final EditCardPackDto packDto; late final EditCardPackDto packDto;
try { try {
packDto = EditCardPackDto.fromJson(jsonDecode(body) as Map<String, dynamic>); packDto =
EditCardPackDto.fromJson(jsonDecode(body) as Map<String, dynamic>);
} catch (_) { } catch (_) {
return _json( return _json(
{ {

View file

@ -53,15 +53,16 @@ Middleware authorizeV2(UserManager userManager, JwtService jwtService) {
} }
// Check if this is a public auth endpoint - always allow for all HTTP methods // Check if this is a public auth endpoint - always allow for all HTTP methods
if (normalizedPath.startsWith('/auth/') || normalizedPath.startsWith('/admin/auth/')) { if (normalizedPath.startsWith('/auth/') ||
normalizedPath.startsWith('/admin/auth/')) {
if (_publicAuthPaths.contains(normalizedPath)) { if (_publicAuthPaths.contains(normalizedPath)) {
// Auth endpoints in public list are accessible without token // Auth endpoints in public list are accessible without token
return await innerHandler(request); return await innerHandler(request);
} }
// Check for dynamic auth paths (e.g., /auth/telegram/code-status/<code>) // Check for dynamic auth paths (e.g., /auth/telegram/code-status/<code>)
if (normalizedPath.startsWith('/auth/telegram/code-status') if (normalizedPath.startsWith('/auth/telegram/code-status') ||
|| normalizedPath.startsWith('/admin/auth/request-code') normalizedPath.startsWith('/admin/auth/request-code') ||
|| normalizedPath.startsWith('/admin/auth/verify-code')) { normalizedPath.startsWith('/admin/auth/verify-code')) {
// Code status endpoint is public (used before authentication) // Code status endpoint is public (used before authentication)
return await innerHandler(request); return await innerHandler(request);
} }

View file

@ -491,9 +491,7 @@ class PacksApiV2 {
final items = voices final items = voices
.where((voice) => voice.id != null) .where((voice) => voice.id != null)
.map( .map(
(voice) => voice (voice) => voice.toDto(url: '/api/v2/voice/${voice.id}').toJson(),
.toDto(url: '/api/v2/voice/${voice.id}')
.toJson(),
) )
.toList(); .toList();
@ -563,7 +561,8 @@ class PacksApiV2 {
} }
String? _sanitizeVoicePath(String path) { String? _sanitizeVoicePath(String path) {
final normalized = path.replaceAll('\\', '/').replaceFirst(RegExp('^/'), ''); final normalized =
path.replaceAll('\\', '/').replaceFirst(RegExp('^/'), '');
if (normalized.contains('..')) { if (normalized.contains('..')) {
return null; return null;
} }

View file

@ -61,8 +61,7 @@ class PromocodesApiV2 {
return _unauthorized(); return _unauthorized();
} }
final campaigns = final campaigns = await _promoCodesManager.listAvailablePromocodes(user);
await _promoCodesManager.listAvailablePromocodes(user);
return _json({ return _json({
'campaigns': campaigns.map((c) => c.toJson()).toList(), 'campaigns': campaigns.map((c) => c.toJson()).toList(),
}); });

View file

@ -91,12 +91,15 @@ class SubscriptionsApiV2 {
return _unauthorized(); return _unauthorized();
} }
final subscriptionDto = await _subscriptionManager.getSubscriptionDto(user); final subscriptionDto =
await _subscriptionManager.getSubscriptionDto(user);
return _ok({ return _ok({
'active': subscriptionDto.isActive, 'active': subscriptionDto.isActive,
if (subscriptionDto.start != null) 'start': subscriptionDto.start!.toIso8601String(), if (subscriptionDto.start != null)
if (subscriptionDto.finish != null) 'finish': subscriptionDto.finish!.toIso8601String(), 'start': subscriptionDto.start!.toIso8601String(),
if (subscriptionDto.finish != null)
'finish': subscriptionDto.finish!.toIso8601String(),
}); });
} catch (e, s) { } catch (e, s) {
developer.log( developer.log(

View file

@ -216,11 +216,13 @@ class TelegramBotApiV2 {
if (users.length > 1) { if (users.length > 1) {
return _ok({ return _ok({
'multiple': true, 'multiple': true,
'users': users.map((u) => { 'users': users
.map((u) => {
'id': u.id, 'id': u.id,
'email': u.email, 'email': u.email,
'name': u.name, 'name': u.name,
}).toList(), })
.toList(),
}); });
} }
@ -235,10 +237,12 @@ class TelegramBotApiV2 {
'email': user.email, 'email': user.email,
'name': user.name, 'name': user.name,
'tags': user.userData.value?.tags.join(',') ?? '', 'tags': user.userData.value?.tags.join(',') ?? '',
'packs': user.packs.map((p) => { 'packs': user.packs
.map((p) => {
'id': p.id, 'id': p.id,
'title': p.title, 'title': p.title,
}).toList(), })
.toList(),
'purchases': user.purchases.length, 'purchases': user.purchases.length,
'subscription': sub == null 'subscription': sub == null
? null ? null
@ -260,12 +264,14 @@ class TelegramBotApiV2 {
return _ok({ return _ok({
'total': users.length, 'total': users.length,
'users': users.map((user) => { 'users': users
.map((user) => {
'id': user.id, 'id': user.id,
'email': user.email ?? '${user.id} ${user.name}', 'email': user.email ?? '${user.id} ${user.name}',
'lastTimeOnline': user.userData.value?.lastTimeOnline 'lastTimeOnline':
?.toIso8601String(), user.userData.value?.lastTimeOnline?.toIso8601String(),
}).toList(), })
.toList(),
}); });
} }
} catch (e, s) { } catch (e, s) {

View file

@ -29,7 +29,8 @@ Middleware telegramBotAuthMiddleware() {
return Response( return Response(
500, 500,
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: '{"error":"Internal Server Error","message":"API key not configured"}', body:
'{"error":"Internal Server Error","message":"API key not configured"}',
); );
} }

View file

@ -475,7 +475,8 @@ class UsersApiV2 {
studyDates.add(now); studyDates.add(now);
// Update total study time // Update total study time
final totalStudyTime = userData.totalStudyTimeMinutes + sessionDto.duration.inMinutes; final totalStudyTime =
userData.totalStudyTimeMinutes + sessionDto.duration.inMinutes;
// Recalculate streak // Recalculate streak
final currentStreak = _statisticsCalculator.calculateStreak(studyDates); final currentStreak = _statisticsCalculator.calculateStreak(studyDates);

View file

@ -58,8 +58,10 @@ class PackDtoConverter {
'icons/ad.webp', 'icons/ad.webp',
position: PackTipPosition.bottomRight, position: PackTipPosition.bottomRight,
) )
: AssetPackTip('icons/lock.webp', : AssetPackTip(
position: PackTipPosition.bottomRight,), 'icons/lock.webp',
position: PackTipPosition.bottomRight,
),
version: model.version, version: model.version,
); );
} }

View file

@ -384,11 +384,16 @@ class PackManager {
String appVersion, String appVersion,
) async { ) async {
final pack = await _fetchPackModel(packId); final pack = await _fetchPackModel(packId);
try {
final archiveFile = File( final archiveFile = File(
'${assetsDirectory.path}/pack_archives/${appVersion}/${packId}_${pack.version}.zip', '${assetsDirectory.path}/pack_archives/${appVersion}/${packId}_${pack.version}.zip',
); );
if (await archiveFile.exists()) { if (await archiveFile.exists()) {
return archiveFile.readAsBytes(); return await archiveFile.readAsBytes();
}
} catch (e, s) {
log('Error when fetching pack images archive', error: e, stackTrace: s);
return [];
} }
final cards = pack.cards; final cards = pack.cards;
final rawData = _fetchPackImages(cards.toList()); final rawData = _fetchPackImages(cards.toList());

View file

@ -226,8 +226,10 @@ class PromoCodesManager {
final upperCode = code.toUpperCase(); final upperCode = code.toUpperCase();
// Check if code exists // Check if code exists
final codeModel = final codeModel = await isar.promoCodeModels
await isar.promoCodeModels.filter().codeEqualTo(upperCode).findFirst(); .filter()
.codeEqualTo(upperCode)
.findFirst();
await codeModel?.campaign.load(); await codeModel?.campaign.load();
final campaign = codeModel?.campaign.value; final campaign = codeModel?.campaign.value;
if (codeModel == null || campaign == null) { if (codeModel == null || campaign == null) {

View file

@ -53,9 +53,8 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
final questionType = type ?? _getRandomQuestionType(); final questionType = type ?? _getRandomQuestionType();
final questionText = final questionText =
questionType.translationQuestion ? answerItem.translation : null; questionType.translationQuestion ? answerItem.translation : null;
final questionImage = questionType.imageQuestion final questionImage =
? _imageIdToUrl(answerItem.image) questionType.imageQuestion ? _imageIdToUrl(answerItem.image) : null;
: null;
String? questionAudio = null; String? questionAudio = null;
if (questionType.audioQuestion) { if (questionType.audioQuestion) {
@ -116,7 +115,6 @@ class InputButtonsQuestionGenerator implements QuestionGenerator {
); );
} }
Future<InputButtonsTestQuestionBody> verbTestQuestion( Future<InputButtonsTestQuestionBody> verbTestQuestion(
TestDataItem answerCard) async { TestDataItem answerCard) async {
final ends = ['o', 'as', 'a', 'a', 'a', 'amos', 'ais', 'an', 'an', 'an']; final ends = ['o', 'as', 'a', 'a', 'a', 'amos', 'ais', 'an', 'an', 'an'];

View file

@ -52,9 +52,8 @@ class SimpleQuestionGenerator implements QuestionGenerator {
: questionType.translationQuestion : questionType.translationQuestion
? answerCard.translation ? answerCard.translation
: null; : null;
final questionImage = questionType.imageQuestion final questionImage =
? _imageIdToUrl(answerCard.image) questionType.imageQuestion ? _imageIdToUrl(answerCard.image) : null;
: null;
String? questionAudio = null; String? questionAudio = null;
if (questionType.audioQuestion) { if (questionType.audioQuestion) {
if (questionType.translationAnswers || questionType.imagesAnswers) { if (questionType.translationAnswers || questionType.imagesAnswers) {
@ -93,7 +92,6 @@ class SimpleQuestionGenerator implements QuestionGenerator {
); );
} }
Future<SimpleTestQuestionBody> verbTestQuestion( Future<SimpleTestQuestionBody> verbTestQuestion(
TestDataItem answerCard, { TestDataItem answerCard, {
bool withAudio = true, bool withAudio = true,

View file

@ -76,9 +76,11 @@ class TelegramUtils {
} }
/// Send message to admin via Telegram Bot API /// Send message to admin via Telegram Bot API
static Future<bool> sendMessageToAdmin(String telegramUserId, String message) async { static Future<bool> sendMessageToAdmin(
String telegramUserId, String message) async {
try { try {
final url = Uri.parse('https://api.telegram.org/bot$_botToken/sendMessage'); final url =
Uri.parse('https://api.telegram.org/bot$_botToken/sendMessage');
final response = await http.post( final response = await http.post(
url, url,