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
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:
parent
75048f4f33
commit
a3a5a55fdc
20 changed files with 123 additions and 90 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ class PacksApiV2 {
|
|||
// getBuyPage may throw if pack doesn't exist (via _fetchPackModel)
|
||||
// _fetchPackModel uses ! operator which throws on null
|
||||
final errorString = e.toString();
|
||||
if (errorString.contains('Null check') ||
|
||||
if (errorString.contains('Null check') ||
|
||||
errorString.contains('null') ||
|
||||
e is StateError) {
|
||||
return _notFound('Pack not found');
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
@ -92,7 +91,7 @@ class PromocodesApiV2 {
|
|||
}
|
||||
|
||||
final result = await _promoCodesManager.validatePromocode(code, user);
|
||||
|
||||
|
||||
// Return 404 if code not found
|
||||
if (!result['valid'] && result['message'] == 'Промокод не найден') {
|
||||
return _json(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"}',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -18,4 +18,4 @@ class AdminIdsService {
|
|||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue