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
# Healthcheck - проверка доступности сервера
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=5 \
CMD sh -c 'curl -sf http://127.0.0.1:${PORT:-3000}/health || exit 1'
# Increased start-period to 30s to allow server initialization (Isar, cron jobs, etc.)
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
CMD ["/app/server"]

View file

@ -45,7 +45,8 @@ class MnemoShelf {
final port = int.tryParse(portArg ?? '') ?? 3000;
final certs = Platform.environment['CERTS_PATH']; // null = HTTP without SSL
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)
final v2Routers = [
@ -250,8 +251,8 @@ class MnemoShelf {
// }
return SecurityContext(withTrustedRoots: false);
// ..useCertificateChain(serverCert)
// ..usePrivateKey(key);
// ..useCertificateChain(serverCert)
// ..usePrivateKey(key);
}
logger(String tag) => (String msg, bool isError) {

View file

@ -67,24 +67,32 @@ class AdminAnalyticsApiV2 {
final paymentCount = await backend_main.isar.paymentModels.count();
// 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
..sort((a, b) => (b.id ?? 0).compareTo(a.id ?? 0));
final recentUsers = sortedUsers.take(10).map((u) => {
'id': u.id,
'name': u.name,
'email': u.email,
'createdAt': DateTime(1999).toIso8601String(),
}).toList();
final recentUsers = sortedUsers
.take(10)
.map((u) => {
'id': u.id,
'name': u.name,
'email': u.email,
'createdAt': DateTime(1999).toIso8601String(),
})
.toList();
// 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 topPacks = allPacks.take(5).map((p) => {
'id': p.id,
'title': p.title,
'cards': p.cards.length,
'enabled': p.enabled,
}).toList();
final allPacks = await backend_main.isar
.txn(() async => backend_main.isar.cardPackModels.where().findAll());
final topPacks = allPacks
.take(5)
.map((p) => {
'id': p.id,
'title': p.title,
'cards': p.cards.length,
'enabled': p.enabled,
})
.toList();
return _json({
'stats': {
@ -126,7 +134,8 @@ class AdminAnalyticsApiV2 {
for (int i = 29; i >= 0; 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({
'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
// 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)
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;
if (user == null) {

View file

@ -94,7 +94,8 @@ class AdminCardsApiV2 {
}
// 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
List<GameCardModel> filteredCards = allCards;
@ -102,10 +103,12 @@ class AdminCardsApiV2 {
final searchLower = search.toLowerCase();
filteredCards = allCards.where((card) {
return card.original.toLowerCase().contains(searchLower) ||
card.translation.toLowerCase().contains(searchLower) ||
card.mnemo.toLowerCase().contains(searchLower) ||
(card.transcription?.toLowerCase().contains(searchLower) ?? false) ||
(card.transcriptionMnemo?.toLowerCase().contains(searchLower) ?? false);
card.translation.toLowerCase().contains(searchLower) ||
card.mnemo.toLowerCase().contains(searchLower) ||
(card.transcription?.toLowerCase().contains(searchLower) ??
false) ||
(card.transcriptionMnemo?.toLowerCase().contains(searchLower) ??
false);
}).toList();
}
@ -184,7 +187,8 @@ class AdminCardsApiV2 {
late final GameCardDto cardDto;
try {
cardDto = GameCardDto.fromJson(jsonDecode(body) as Map<String, dynamic>);
cardDto =
GameCardDto.fromJson(jsonDecode(body) as Map<String, dynamic>);
} catch (_) {
return _json(
{

View file

@ -98,14 +98,16 @@ class AdminPacksApiV2 {
}
// Get all packs (admin can see disabled packs)
final allPacks = await backend_main.isar.txn(() async => backend_main.isar.cardPackModels.where().findAll());
List<CardPackModel> filteredPacks = showDisabled ? allPacks : allPacks.where((p) => p.enabled).toList();
final allPacks = await backend_main.isar
.txn(() async => backend_main.isar.cardPackModels.where().findAll());
List<CardPackModel> filteredPacks =
showDisabled ? allPacks : allPacks.where((p) => p.enabled).toList();
if (search != null && search.isNotEmpty) {
final searchLower = search.toLowerCase();
filteredPacks = allPacks.where((pack) {
return pack.title.toLowerCase().contains(searchLower) ||
(pack.subtitle?.toLowerCase().contains(searchLower) ?? false) ||
pack.id.toString().contains(searchLower);
(pack.subtitle?.toLowerCase().contains(searchLower) ?? false) ||
pack.id.toString().contains(searchLower);
}).toList();
}
@ -119,11 +121,8 @@ class AdminPacksApiV2 {
final paginatedPacks = filteredPacks.skip(offset).take(limit).toList();
// Convert to preview DTOs
final packDtos = await Future.wait(
paginatedPacks.map((pack) async =>
await _packDtoConverter.toCardPackPreviewDto(pack, null)
)
);
final packDtos = await Future.wait(paginatedPacks.map((pack) async =>
await _packDtoConverter.toCardPackPreviewDto(pack, null)));
return _json({
'items': packDtos.map((p) => p.toJson()).toList(),
@ -194,7 +193,8 @@ class AdminPacksApiV2 {
late final EditCardPackDto packDto;
try {
packDto = EditCardPackDto.fromJson(jsonDecode(body) as Map<String, dynamic>);
packDto =
EditCardPackDto.fromJson(jsonDecode(body) as Map<String, dynamic>);
} catch (_) {
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
if (normalizedPath.startsWith('/auth/') || normalizedPath.startsWith('/admin/auth/')) {
if (normalizedPath.startsWith('/auth/') ||
normalizedPath.startsWith('/admin/auth/')) {
if (_publicAuthPaths.contains(normalizedPath)) {
// Auth endpoints in public list are accessible without token
return await innerHandler(request);
}
// Check for dynamic auth paths (e.g., /auth/telegram/code-status/<code>)
if (normalizedPath.startsWith('/auth/telegram/code-status')
|| normalizedPath.startsWith('/admin/auth/request-code')
|| normalizedPath.startsWith('/admin/auth/verify-code')) {
if (normalizedPath.startsWith('/auth/telegram/code-status') ||
normalizedPath.startsWith('/admin/auth/request-code') ||
normalizedPath.startsWith('/admin/auth/verify-code')) {
// Code status endpoint is public (used before authentication)
return await innerHandler(request);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -29,7 +29,8 @@ Middleware telegramBotAuthMiddleware() {
return Response(
500,
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);
// Update total study time
final totalStudyTime = userData.totalStudyTimeMinutes + sessionDto.duration.inMinutes;
final totalStudyTime =
userData.totalStudyTimeMinutes + sessionDto.duration.inMinutes;
// Recalculate streak
final currentStreak = _statisticsCalculator.calculateStreak(studyDates);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -76,9 +76,11 @@ class TelegramUtils {
}
/// 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 {
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(
url,