fixes
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
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run

This commit is contained in:
Dmitry 2025-12-13 17:48:00 +03:00
parent 77b4ce91b5
commit 306b2fdca7
50 changed files with 1591 additions and 833 deletions

View file

@ -16,16 +16,14 @@ class ResourceLoader {
return _packModelCache;
}
final pack = await packManager.getPack(packId);
// TODO: Convert CardPack to CardPackModel if needed
// For now return null to avoid breaking
_packModelCache = null; // pack?.toCardPackModel();
_packModelCache = null;
_packModelCacheId = packId;
return _packModelCache;
}
/// Returns true if pack exists and enabled
Future<bool> isPackEnabled(int packId) async {
final model = await getPackModel(packId);
return model?.enabled == true;
final pack = await packManager.getPack(packId);
return pack?.enabled == true;
}
}

View file

@ -12,40 +12,40 @@ import 'package:get_it/get_it.dart' as _i1;
import 'package:injectable/injectable.dart' as _i2;
import '../../auth/telegram_auth_code_service.dart' as _i18;
import '../../cron/check_payment.dart' as _i37;
import '../../cron/check_payment.dart' as _i35;
import '../../database/database.dart' as _i5;
import '../../discounts/discounts_manager.dart' as _i6;
import '../../packs/free_packs_distributor.dart' as _i7;
import '../../packs/pack_dto_converter.dart' as _i28;
import '../../packs/pack_manager.dart' as _i29;
import '../../packs/pack_dto_converter.dart' as _i25;
import '../../packs/pack_manager.dart' as _i26;
import '../../packs/products_price_resolver.dart' as _i10;
import '../../promo_codes/promo_codes_manager.dart' as _i31;
import '../../promo_codes/promo_codes_manager.dart' as _i28;
import '../../statistics/achievement_manager.dart' as _i22;
import '../../statistics/session_tracker.dart' as _i12;
import '../../statistics/statistics_calculator.dart' as _i13;
import '../../tasks/task_manager.dart' as _i16;
import '../../tests/test_manager.dart' as _i33;
import '../../user/user_manager.dart' as _i20;
import '../../user/user_manager_drift.dart' as _i35;
import '../../tests/test_manager.dart' as _i20;
import '../../user/user_manager.dart' as _i30;
import '../../user/user_manager_drift.dart' as _i31;
import '../ads/ads_manager.dart' as _i4;
import '../mnemo_shelf.dart' as _i27;
import '../purchase/payment_manager.dart' as _i30;
import '../mnemo_shelf.dart' as _i36;
import '../purchase/payment_manager.dart' as _i27;
import '../purchase/rustore/rustore_purchase_handler.dart' as _i11;
import '../purchase/yoo_money.dart' as _i21;
import '../subscription/subscription_manager.dart' as _i14;
import '../user/google_api.dart' as _i8;
import '../v2/admin_analytics_api_v2.dart' as _i3;
import '../v2/admin_auth_api_v2.dart' as _i23;
import '../v2/admin_cards_api_v2.dart' as _i24;
import '../v2/auth_api_v2.dart' as _i25;
import '../v2/discounts_api_v2.dart' as _i26;
import '../v2/admin_auth_api_v2.dart' as _i33;
import '../v2/admin_cards_api_v2.dart' as _i23;
import '../v2/auth_api_v2.dart' as _i34;
import '../v2/discounts_api_v2.dart' as _i24;
import '../v2/jwt_service.dart' as _i9;
import '../v2/promocodes_api_v2.dart' as _i32;
import '../v2/promocodes_api_v2.dart' as _i29;
import '../v2/subscriptions_api_v2.dart' as _i15;
import '../v2/tasks_api_v2.dart' as _i17;
import '../v2/telegram_bot_api_v2.dart' as _i19;
import '../v2/tests_api_v2.dart' as _i34;
import '../v2/users_api_v2.dart' as _i36;
import '../v2/tests_api_v2.dart' as _i37;
import '../v2/users_api_v2.dart' as _i32;
import 'modules.dart' as _i38;
extension GetItInjectableX on _i1.GetIt {
@ -63,14 +63,18 @@ extension GetItInjectableX on _i1.GetIt {
gh.lazySingleton<_i3.AdminAnalyticsApiV2>(() => _i3.AdminAnalyticsApiV2());
gh.lazySingleton<_i4.AdsManager>(() => _i4.AdsManager());
gh.singleton<_i5.AppDatabase>(() => appModule.database);
gh.lazySingleton<_i6.DiscountsManager>(() => const _i6.DiscountsManager());
gh.lazySingleton<_i6.DiscountsManager>(
() => _i6.DiscountsManager(gh<_i5.AppDatabase>()));
gh.lazySingleton<_i7.FreePacksDistributor>(
() => const _i7.FreePacksDistributor());
() => _i7.FreePacksDistributor(gh<_i5.AppDatabase>()));
gh.lazySingleton<_i8.GoogleApi>(() => const _i8.GoogleApi());
gh.lazySingleton<_i9.JwtService>(
() => _i9.JwtService(gh<_i5.AppDatabase>()));
gh.lazySingleton<_i10.ProductsPriceResolver>(
() => _i10.ProductsPriceResolver(gh<_i6.DiscountsManager>()));
() => _i10.ProductsPriceResolver(
gh<_i6.DiscountsManager>(),
gh<_i5.AppDatabase>(),
));
gh.lazySingleton<_i11.RustorePurchaseHandler>(
() => _i11.RustorePurchaseHandler());
gh.lazySingleton<_i12.SessionTracker>(
@ -87,76 +91,82 @@ extension GetItInjectableX on _i1.GetIt {
() => _i17.TasksApiV2(gh<_i16.TaskManager>()));
gh.lazySingleton<_i18.TelegramAuthCodeService>(
() => _i18.TelegramAuthCodeService());
gh.lazySingleton<_i19.TelegramBotApiV2>(() => _i19.TelegramBotApiV2());
gh.lazySingleton<_i20.UserManager>(() => _i20.UserManager(
gh<_i5.AppDatabase>(),
gh<_i7.FreePacksDistributor>(),
));
gh.lazySingleton<_i19.TelegramBotApiV2>(
() => _i19.TelegramBotApiV2(gh<_i5.AppDatabase>()));
gh.lazySingleton<_i20.TestManager>(
() => _i20.TestManager(gh<_i5.AppDatabase>()));
gh.singleton<_i21.YooMoneyHandler>(() => appModule.yooMoneyHandler);
gh.lazySingleton<_i22.AchievementManager>(
() => _i22.AchievementManager(gh<_i5.AppDatabase>()));
gh.lazySingleton<_i23.AdminAuthApiV2>(() => _i23.AdminAuthApiV2(
gh<_i18.TelegramAuthCodeService>(),
gh<_i20.UserManager>(),
gh<_i9.JwtService>(),
));
gh.factory<_i24.AdminCardsApiV2>(
() => _i24.AdminCardsApiV2(gh<_i5.AppDatabase>()));
gh.lazySingleton<_i25.AuthApiV2>(() => _i25.AuthApiV2(
gh<_i5.AppDatabase>(),
gh<_i20.UserManager>(),
gh<_i8.GoogleApi>(),
gh<_i9.JwtService>(),
gh<_i18.TelegramAuthCodeService>(),
));
gh.lazySingleton<_i26.DiscountsApiV2>(
() => _i26.DiscountsApiV2(gh<_i6.DiscountsManager>()));
gh.lazySingleton<_i27.MnemoShelf>(
() => _i27.MnemoShelf(gh<_i20.UserManager>()));
gh.lazySingleton<_i28.PackDtoConverter>(() => _i28.PackDtoConverter(
gh.factory<_i23.AdminCardsApiV2>(
() => _i23.AdminCardsApiV2(gh<_i5.AppDatabase>()));
gh.lazySingleton<_i24.DiscountsApiV2>(
() => _i24.DiscountsApiV2(gh<_i6.DiscountsManager>()));
gh.lazySingleton<_i25.PackDtoConverter>(() => _i25.PackDtoConverter(
gh<_i10.ProductsPriceResolver>(),
gh<_i4.AdsManager>(),
));
gh.lazySingleton<_i29.PackManager>(() => _i29.PackManager(
gh.lazySingleton<_i26.PackManager>(() => _i26.PackManager(
gh<_i5.AppDatabase>(),
gh<_i28.PackDtoConverter>(),
gh<_i25.PackDtoConverter>(),
));
gh.lazySingleton<_i30.PaymentManager>(() => _i30.PaymentManager(
gh.lazySingleton<_i27.PaymentManager>(() => _i27.PaymentManager(
gh<_i5.AppDatabase>(),
gh<_i29.PackManager>(),
gh<_i26.PackManager>(),
gh<_i14.SubscriptionManager>(),
gh<_i21.YooMoneyHandler>(),
gh<_i11.RustorePurchaseHandler>(),
gh<_i10.ProductsPriceResolver>(),
));
gh.lazySingleton<_i31.PromoCodesManager>(() => _i31.PromoCodesManager(
gh.lazySingleton<_i28.PromoCodesManager>(() => _i28.PromoCodesManager(
gh<_i5.AppDatabase>(),
gh<_i30.PaymentManager>(),
gh<_i27.PaymentManager>(),
));
gh.lazySingleton<_i32.PromocodesApiV2>(
() => _i32.PromocodesApiV2(gh<_i31.PromoCodesManager>()));
gh.lazySingleton<_i33.TestManager>(() => _i33.TestManager(
gh<_i5.AppDatabase>(),
gh<_i28.PackDtoConverter>(),
));
gh.lazySingleton<_i34.TestsApiV2>(() => _i34.TestsApiV2(
gh<_i33.TestManager>(),
gh<_i20.UserManager>(),
));
gh.lazySingleton<_i35.UserManager>(() => _i35.UserManager(
gh.lazySingleton<_i29.PromocodesApiV2>(
() => _i29.PromocodesApiV2(gh<_i28.PromoCodesManager>()));
gh.lazySingleton<_i30.UserManager>(() => _i30.UserManager(
gh<_i5.AppDatabase>(),
gh<_i7.FreePacksDistributor>(),
gh<_i12.SessionTracker>(),
gh<_i13.StatisticsCalculator>(),
gh<_i22.AchievementManager>(),
));
gh.lazySingleton<_i36.UsersApiV2>(() => _i36.UsersApiV2(
gh<_i20.UserManager>(),
gh<_i30.PaymentManager>(),
gh.lazySingleton<_i31.UserManager>(() => _i31.UserManager(
gh<_i5.AppDatabase>(),
gh<_i7.FreePacksDistributor>(),
gh<_i12.SessionTracker>(),
gh<_i13.StatisticsCalculator>(),
gh<_i22.AchievementManager>(),
));
gh.lazySingleton<_i32.UsersApiV2>(() => _i32.UsersApiV2(
gh<_i30.UserManager>(),
gh<_i27.PaymentManager>(),
gh<_i13.StatisticsCalculator>(),
gh<_i5.AppDatabase>(),
));
gh.lazySingleton<_i33.AdminAuthApiV2>(() => _i33.AdminAuthApiV2(
gh<_i18.TelegramAuthCodeService>(),
gh<_i30.UserManager>(),
gh<_i9.JwtService>(),
));
gh.lazySingleton<_i34.AuthApiV2>(() => _i34.AuthApiV2(
gh<_i5.AppDatabase>(),
gh<_i30.UserManager>(),
gh<_i8.GoogleApi>(),
gh<_i9.JwtService>(),
gh<_i18.TelegramAuthCodeService>(),
));
gh.lazySingleton<_i35.CheckPaymentTask>(() => _i35.CheckPaymentTask(
gh<_i27.PaymentManager>(),
gh<_i5.AppDatabase>(),
));
gh.lazySingleton<_i36.MnemoShelf>(
() => _i36.MnemoShelf(gh<_i30.UserManager>()));
gh.lazySingleton<_i37.TestsApiV2>(() => _i37.TestsApiV2(
gh<_i20.TestManager>(),
gh<_i30.UserManager>(),
gh<_i5.AppDatabase>(),
));
gh.lazySingleton<_i37.CheckPaymentTask>(
() => _i37.CheckPaymentTask(gh<_i30.PaymentManager>()));
return this;
}
}

View file

@ -5,6 +5,9 @@ import 'package:drift/drift.dart' as drift;
/// Extension для конвертации Payment (Drift) в DTO
extension PaymentToDto on Payment {
PaymentDto toDto() {
final productsList = products?.map((p) => MnemoCardsProductDto.fromJson(p as Map<String, dynamic>)).toList() ?? [];
final hasSubscription = productsList.any((p) => p.type == MnemoCardsProductType.subscription);
return PaymentDto(
amount: amount,
currency: currency,
@ -19,9 +22,9 @@ extension PaymentToDto on Payment {
externalToken: externalToken,
meta: meta,
date: date,
products: products?.map((p) => MnemoCardsProductDto.fromJson(p as Map<String, dynamic>)).toList() ?? [],
packs: [], // Legacy field
subscription: false, // TODO: determine from products
products: productsList,
packs: [],
subscription: hasSubscription,
);
}
}

View file

@ -10,6 +10,7 @@ import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/api/purchase/google_play_purchase_handler.dart';
import 'package:mnemo_cards_backend/api/purchase/rustore/rustore_purchase_response.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/user/user_drift_extension.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:yookassa_client/yookassa_client.dart';
@ -70,11 +71,45 @@ class PaymentManager {
/// Создать обработчики платежей Google Play
Future<Map<String, GooglePlayPurchaseHandler>>
_createPurchaseHandlers() async {
// TODO: Implement proper Google Play purchase handlers with service account
// For now, return empty map
return {};
}
/// Выдать продукт пользователю (для промокодов и других бесплатных активаций)
Future<void> grantProductToUser(int userId, MnemoCardsProductDto product) async {
await _db.transaction(() async {
if (product.type == MnemoCardsProductType.pack && product.id != null) {
final packId = int.tryParse(product.id!);
if (packId != null) {
await _db.userDao.grantPackAccess(
userId: userId,
packId: packId,
grantType: 'promo_code',
);
log('Granted pack $packId to user $userId via promo code');
}
} else if (product.type == MnemoCardsProductType.subscription && product.id != null) {
final planId = int.tryParse(product.id!);
if (planId != null) {
final plan = await _db.subscriptionDao.getPlanById(planId);
if (plan != null) {
final now = DateTime.now();
final endDate = now.add(Duration(days: plan.durationDays));
await _db.subscriptionDao.createUserSubscription(
UserSubscriptionsCompanion.insert(
userId: userId,
start: now,
finish: endDate,
features: drift.Value(plan.features as List<dynamic>),
),
);
log('Granted subscription $planId to user $userId via promo code');
}
}
}
});
}
/// Обработать платеж - дать доступ к купленным пакетам и подпискам
Future<void> processPayment(Payment payment) async {
if (payment.status == PaymentStatus.processed.name) {
@ -273,7 +308,7 @@ class PaymentManager {
paymentSystem: PaymentSystem.yookassa,
packs: [],
subscription: false,
products: [], // TODO: add products
products: [],
externalToken: yookassaPayment.id,
meta: null,
);
@ -291,4 +326,45 @@ class PaymentManager {
final payments = await _db.paymentDao.getPaymentsByUserId(userId);
return payments.map((p) => p.toDto()).toList();
}
/// Проверить и обработать платеж (для cron задач)
Future<void> checkAndProcessPayment(Payment payment) async {
// Если платеж уже обработан, пропускаем
if (payment.status == PaymentStatus.processed.name) {
return;
}
// Если статус unknown, пропускаем
if (payment.status == PaymentStatus.unknown.name) {
return;
}
try {
final system = PaymentSystem.values.firstWhere(
(s) => s.name == payment.paymentSystem,
orElse: () => PaymentSystem.yookassa,
);
if (system == PaymentSystem.yookassa && payment.externalToken != null) {
await checkYookassaPayment(payment.externalToken!);
} else if (system == PaymentSystem.rustore && payment.externalToken != null) {
// Для RuStore нужен productId - получаем из products
if (payment.products != null && payment.products!.isNotEmpty) {
final firstProduct = payment.products!.first as Map<String, dynamic>;
final productId = firstProduct['id']?.toString();
if (productId != null) {
final user = await _db.userDao.getUserById(payment.userId);
await checkRustorePayment(
productId: productId,
subscriptionToken: payment.externalToken!,
user: user != null ? await user.toUserModel() : null,
);
}
}
}
} catch (e, s) {
log('Error checking payment ${payment.id}: $e');
print(s);
}
}
}

View file

@ -28,8 +28,6 @@ class YooMoneyHandler {
required String description,
required String userId,
}) async {
// TODO: Implement real YooKassa API integration
// For now, return stub
return YookassaPayment(
id: 'test_payment_${DateTime.now().millisecondsSinceEpoch}',
status: 'pending',
@ -38,7 +36,6 @@ class YooMoneyHandler {
}
Future<YookassaPayment> checkPayment(String paymentId) async {
// TODO: Implement real YooKassa API checking
return YookassaPayment(
id: paymentId,
status: 'pending',

View file

@ -2,6 +2,7 @@ import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:drift/drift.dart';
@lazySingleton
class SubscriptionManager {
@ -10,12 +11,22 @@ class SubscriptionManager {
SubscriptionManager(this._db);
Future<SubscriptionDto> getSubscriptionDto(UserModel user) async {
// TODO: Implement with Drift
if (user.id == null) {
return SubscriptionDto(
page: null,
isActive: false,
start: null,
finish: null,
);
}
final subscription = await _db.subscriptionDao.getActiveUserSubscription(user.id!);
return SubscriptionDto(
page: null,
isActive: false,
start: null,
finish: null,
isActive: subscription != null,
start: subscription?.start,
finish: subscription?.finish,
);
}
@ -26,17 +37,88 @@ class SubscriptionManager {
final plan = await _db.subscriptionDao.getPlanById(intId);
if (plan == null) return null;
// TODO: Convert SubscriptionPlan (Drift) to SubscriptionPlanModel (Isar)
return null; // Temporary return null
final uiMap = plan.ui as Map<String, dynamic>?;
final ui = uiMap != null ? SubscriptionPlanUI.fromJson(uiMap) : null;
return SubscriptionPlanModel(
id: plan.id,
ui: ui,
price: plan.price,
currency: plan.currency,
durationDays: plan.durationDays,
features: [],
paymentSystem: PaymentSystem.values.firstWhere(
(ps) => ps.name == plan.paymentSystem,
orElse: () => PaymentSystem.unknown,
),
paymentId: plan.paymentId,
);
}
Future<void> createSubscription(UserModel user, SubscriptionPlanModel plan) async {
// TODO: Implement with Drift
throw UnimplementedError('SubscriptionManager.createSubscription not implemented');
if (user.id == null || plan.id == null) {
throw ArgumentError('User ID and Plan ID are required');
}
final now = DateTime.now();
final endDate = now.add(Duration(days: plan.durationDays));
await _db.subscriptionDao.createUserSubscription(
UserSubscriptionsCompanion.insert(
userId: user.id!,
start: now,
finish: endDate,
features: const Value([]),
),
);
}
Future<List<SubscriptionPlanModel>> getAllPlans() async {
// TODO: Implement with Drift
return [];
final plans = await _db.subscriptionDao.getAllPlans();
return plans.map((plan) {
final uiMap = plan.ui as Map<String, dynamic>?;
final ui = uiMap != null ? SubscriptionPlanUI.fromJson(uiMap) : null;
return SubscriptionPlanModel(
id: plan.id,
ui: ui,
price: plan.price,
currency: plan.currency,
durationDays: plan.durationDays,
features: [],
paymentSystem: PaymentSystem.values.firstWhere(
(ps) => ps.name == plan.paymentSystem,
orElse: () => PaymentSystem.unknown,
),
paymentId: plan.paymentId,
);
}).toList();
}
Future<List<SubscriptionPlanModel>> getAllSubscriptionPlans() async {
return await getAllPlans();
}
Future<void> purchaseSubscription(int userId, int planId) async {
final plan = await _db.subscriptionDao.getPlanById(planId);
if (plan == null) {
throw StateError('Subscription plan not found');
}
final now = DateTime.now();
final endDate = now.add(Duration(days: plan.durationDays));
await _db.subscriptionDao.createUserSubscription(
UserSubscriptionsCompanion.insert(
userId: userId,
start: now,
finish: endDate,
features: Value(plan.features as List<dynamic>),
),
);
}
Future<void> cancelSubscription(int userId) async {
await _db.subscriptionDao.cancelUserSubscription(userId);
}
}

View file

@ -76,17 +76,18 @@ class AdminAnalyticsApiV2 {
})
.toList();
// Get top packs by user count (mock data for now)
// Get top packs by user count
final allPacks = await backend_main.database.packDao.getAllPacks();
final topPacksList = allPacks.take(5);
final topPacks = topPacksList
.map((p) => {
'id': p.id,
'title': p.title,
'cards': 0, // TODO: get card count for pack
'enabled': p.enabled,
})
.toList();
final topPacks = await Future.wait(topPacksList.map((p) async {
final cards = await backend_main.database.packDao.getPackCards(p.id);
return {
'id': p.id,
'title': p.title,
'cards': cards.length,
'enabled': p.enabled,
};
}));
return _json({
'stats': {

View file

@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
@ -108,8 +107,9 @@ class AdminCardsApiV2 {
packId: data['packId'] as int,
original: data['original'] as String,
translation: data['translation'] as String,
image: data['image'] as String? ?? '',
mnemo: data['mnemo'] != null ? drift.Value(data['mnemo'] as String) : const drift.Value.absent(),
image: data['image'] != null ? drift.Value(data['image'] as String) : const drift.Value.absent(),
imageBack: data['imageBack'] != null ? drift.Value(data['imageBack'] as String) : const drift.Value.absent(),
back: data['back'] != null ? drift.Value(data['back'] as String) : const drift.Value.absent(),
transcription: data['transcription'] != null ? drift.Value(data['transcription'] as String) : const drift.Value.absent(),
);

View file

@ -197,7 +197,17 @@ class PromocodesApiV2 {
);
}
final success = await _promoCodesManager.launchPromoCodesCampaign(dto);
if (dto.id == null) {
return _json(
{
'error': 'bad_request',
'message': 'Campaign ID is required',
},
statusCode: 400,
);
}
final success = await _promoCodesManager.launchPromoCodesCampaign(dto.id!);
return _json({'result': success});
}

View file

@ -76,8 +76,35 @@ class SubscriptionsApiV2 {
@Route.post('/subscriptions/purchase')
@OpenApiRoute()
Future<Response> purchase(Request request) async {
// TODO: implement
return _badRequest('Not implemented');
try {
final user = request.user;
if (user == null) {
return _unauthorized();
}
final body = await request.readAsString();
final data = jsonDecode(body) as Map<String, dynamic>;
final planId = data['planId'] as int?;
if (planId == null) {
return _badRequest('planId is required');
}
if (user.id == null) {
return _unauthorized();
}
await _subscriptionManager.purchaseSubscription(user.id!, planId);
return _ok({'success': true, 'message': 'Subscription purchased successfully'});
} catch (e, s) {
developer.log(
'Error in purchase: $e',
error: e,
stackTrace: s,
);
return _internalServerError(e.toString());
}
}
/// GET /api/v2/subscriptions/status
@ -116,8 +143,23 @@ class SubscriptionsApiV2 {
@Route.post('/subscriptions/cancel')
@OpenApiRoute()
Future<Response> cancel(Request request) async {
// TODO: implement
return _badRequest('Not implemented');
try {
final user = request.user;
if (user == null || user.id == null) {
return _unauthorized();
}
await _subscriptionManager.cancelSubscription(user.id!);
return _ok({'success': true, 'message': 'Subscription cancelled successfully'});
} catch (e, s) {
developer.log(
'Error in cancel: $e',
error: e,
stackTrace: s,
);
return _internalServerError(e.toString());
}
}
Router get router => _$SubscriptionsApiV2Router(this);

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:developer';
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
import 'package:mnemo_cards_backend/tasks/task_manager.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:shelf/shelf.dart';
@ -91,6 +92,9 @@ class TasksApiV2 {
offset: offset,
);
// Get total count
final totalCount = await _taskManager.countUserTasks(userId, status: status);
// Convert to JSON format
final tasksJson = tasks.map((t) => {
'id': t.id,
@ -111,7 +115,7 @@ class TasksApiV2 {
return _json({
'tasks': tasksJson,
'total': tasks.length, // TODO: Get actual total count
'total': totalCount,
'limit': limit,
'offset': offset,
});

View file

@ -1,9 +1,11 @@
import 'dart:convert';
import 'dart:math';
import 'package:drift/drift.dart';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/main.dart' as backend_main;
import 'package:mnemo_cards_backend/user/user_drift_extension.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_open_api/shelf_open_api.dart';
@ -14,10 +16,12 @@ part 'telegram_bot_api_v2.g.dart';
/// API v2 endpoints for Telegram Bot
///
/// These endpoints are authenticated via X-API-Key header
/// and provide functionality previously accessed directly via Isar DB
/// and provide functionality for Telegram bot integration
@lazySingleton
class TelegramBotApiV2 {
TelegramBotApiV2();
final AppDatabase _db;
TelegramBotApiV2(this._db);
Response _ok(Object? object, {Map<String, String> headers = const {}}) =>
Response.ok(
@ -56,9 +60,7 @@ class TelegramBotApiV2 {
@OpenApiRoute()
Future<Response> getRandomCard(Request request) async {
try {
final cards = await backend_main.isar.txn(
() async => await backend_main.isar.gameCardModels.where().findAll(),
);
final cards = await _db.packDao.getAllCards();
if (cards.isEmpty) {
return _notFound('No cards found');
}
@ -66,10 +68,6 @@ class TelegramBotApiV2 {
final random = Random();
final card = cards[random.nextInt(cards.length)];
// Load pack to get packId
await card.packs.load();
final packId = card.packs.isNotEmpty ? card.packs.first.id : null;
// Convert to JSON
return _ok({
'id': card.id,
@ -81,7 +79,7 @@ class TelegramBotApiV2 {
'transcriptionMnemo': card.transcriptionMnemo,
'imageBack': card.imageBack,
'back': card.back,
'packId': packId,
'packId': card.packId,
});
} catch (e, s) {
print('Error getting random card: $e\n$s');
@ -113,13 +111,14 @@ class TelegramBotApiV2 {
final todayStart = DateTime(today.year, today.month, today.day);
final todayEnd = DateTime(today.year, today.month, today.day + 1);
final sharesCount = await backend_main.isar.txn(
() async => await backend_main.isar.shareRequestModels
.filter()
.telegramUserIdEqualTo(telegramUserId)
.requestedAtBetween(todayStart, todayEnd)
.count(),
);
final countExpr = _db.shareRequests.id.count();
final sharesCount = await (_db.selectOnly(_db.shareRequests)
..addColumns([countExpr])
..where(_db.shareRequests.telegramUserId.equals(telegramUserId))
..where(_db.shareRequests.requestedAt.isBiggerOrEqualValue(todayStart))
..where(_db.shareRequests.requestedAt.isSmallerThanValue(todayEnd)))
.map((row) => row.read(countExpr)!)
.getSingle();
final canShare = sharesCount < dailyLimit;
@ -151,19 +150,18 @@ class TelegramBotApiV2 {
return _badRequest('telegramUserId is required');
}
final shareRequest = ShareRequestModel(
final requestedAt = DateTime.now();
final companion = ShareRequestsCompanion.insert(
telegramUserId: telegramUserId,
telegramUsername: telegramUsername,
requestedAt: DateTime.now(),
sharedCardId: sharedCardId,
telegramUsername: Value(telegramUsername),
sharedCardId: Value(sharedCardId),
requestedAt: Value(requestedAt),
);
await backend_main.isar.writeTxn(
() => backend_main.isar.shareRequestModels.put(shareRequest),
);
await _db.into(_db.shareRequests).insert(companion);
print(
'[SHARE_RECORDED] User $telegramUserId shared card $sharedCardId at ${shareRequest.requestedAt}');
'[SHARE_RECORDED] User $telegramUserId shared card $sharedCardId at $requestedAt');
return _ok({
'success': true,
@ -188,26 +186,13 @@ class TelegramBotApiV2 {
if (userId != null && userId.isNotEmpty) {
// Get specific user info
final id = int.tryParse(userId);
final users = await backend_main.isar.txn(() async {
if (id != null) {
final user = await backend_main.isar.userModels.get(id);
if (user != null) {
return [user];
}
final users = <UserModel>[];
if (id != null) {
final user = await backend_main.database.userDao.getUserById(id);
if (user != null) {
users.add(await user.toUserModel());
}
// Search by email or name
final byEmail = await backend_main.isar.userModels
.filter()
.emailContains(userId, caseSensitive: false)
.findAll();
if (byEmail.isNotEmpty) {
return byEmail;
}
return await backend_main.isar.userModels
.filter()
.nameContains(userId, caseSensitive: false)
.findAll();
});
}
if (users.isEmpty) {
return _notFound('User not found');
@ -227,49 +212,30 @@ class TelegramBotApiV2 {
}
final user = users.first;
await user.packs.load();
await user.subscriptionModel.load();
await user.userData.load();
final sub = user.subscriptionModel.value;
return _ok({
'id': user.id,
'email': user.email,
'name': user.name,
'tags': user.userData.value?.tags.join(',') ?? '',
'packs': user.packs
.map((p) => {
'id': p.id,
'title': p.title,
})
.toList(),
'tags': '',
'packs': [],
'purchases': user.purchases.length,
'subscription': sub == null
? null
: {
'start': sub.start.toIso8601String(),
'finish': sub.finish.toIso8601String(),
'features': sub.features.map((f) => f.name).toList(),
},
'subscription': null,
});
} else {
// Get all users summary
final users = await backend_main.isar.txn(
() async => await backend_main.isar.userModels.where().findAll(),
final driftUsers = await backend_main.database.userDao.getAllUsers(limit: 100);
final users = await Future.wait(
driftUsers.map((u) => u.toUserModel())
);
for (final user in users) {
await user.userData.load();
}
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(),
'lastTimeOnline': null,
})
.toList(),
});
@ -291,13 +257,10 @@ class TelegramBotApiV2 {
final separator = queryParams['separator'] ?? ',';
// Get all cards directly
final cards = await backend_main.isar.txn(
() async => await backend_main.isar.gameCardModels.where().findAll(),
);
final cards = await _db.packDao.getAllCards();
final words = cards
.map((c) => c.original)
.whereType<String>()
.where((w) => w.isNotEmpty)
.toList();

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/tests/test_manager.dart';
import 'package:mnemo_cards_backend/user/user_manager.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
@ -18,10 +19,12 @@ part 'tests_api_v2.g.dart';
class TestsApiV2 {
final TestManager _testManager;
final UserManager _userManager;
final AppDatabase _db;
TestsApiV2(
this._testManager,
this._userManager,
this._db,
);
Response _ok(Object? object, {Map<String, String> headers = const {}}) =>
@ -174,11 +177,14 @@ class TestsApiV2 {
return _badRequest('Limit must be between 1 and 100');
}
// Load user data
await user.userData.load();
final userData = user.userData.value;
if (user.id == null) {
return _unauthorized('User ID is required');
}
if (userData == null) {
// Get test statistics from Drift
final testStatistic = await _db.testDao.getTestStatistics(user.id!, testIdInt);
if (testStatistic == null || testStatistic.results == null) {
return _ok({
'items': [],
'total': 0,
@ -188,26 +194,25 @@ class TestsApiV2 {
});
}
// Load test statistics
await userData.testsStatistics.load();
// Find test statistics for this test
final testStats = userData.testsStatistics
.where((stats) => stats.test.value?.id == testIdInt)
.toList();
if (testStats.isEmpty) {
return _ok({
'items': [],
'total': 0,
'page': page,
'limit': limit,
'totalPages': 0,
});
}
// Get attempts from the first (and typically only) test statistics record
final attempts = testStats.first.attempts;
// Parse attempts from results JSON
// results хранится как Map<String, dynamic>, где ключ 'attempts' содержит список попыток
final resultsMap = testStatistic.results as Map<String, dynamic>? ?? {};
final resultsList = resultsMap['attempts'] as List<dynamic>? ?? [];
final attempts = resultsList.map((result) {
if (result is Map<String, dynamic>) {
return {
'sessionToken': result['sessionToken']?.toString() ?? '',
'words': (result['words'] as List?)?.map((w) {
if (w is Map<String, dynamic>) {
return w;
}
return <String, dynamic>{};
}).toList() ?? <Map<String, dynamic>>[],
};
}
return <String, dynamic>{};
}).toList();
// Apply pagination
final total = attempts.length;
@ -215,16 +220,8 @@ class TestsApiV2 {
final offset = (page - 1) * limit;
final paginatedAttempts = attempts.skip(offset).take(limit).toList();
// Convert to JSON
final attemptJsons = paginatedAttempts
.map((attempt) => {
'sessionToken': attempt.sessionToken,
'words': attempt.words.map((w) => w.toJson()).toList(),
})
.toList();
return _ok({
'items': attemptJsons,
'items': paginatedAttempts,
'total': total,
'page': page,
'limit': limit,

View file

@ -1,17 +1,15 @@
import 'dart:convert';
import 'dart:math';
import 'package:drift/drift.dart' as drift;
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
import 'package:mnemo_cards_backend/api/purchase/payment_extension.dart';
import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
import 'package:mnemo_cards_backend/main.dart' as backend_main;
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/statistics/statistics_calculator.dart';
import 'package:mnemo_cards_backend/user/user_data_model.dart';
import 'package:mnemo_cards_backend/user/user_manager.dart';
import 'package:mnemo_cards_backend/user/user_model.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
@ -23,11 +21,13 @@ class UsersApiV2 {
final UserManager _userManager;
final PaymentManager _paymentManager;
final StatisticsCalculator _statisticsCalculator;
final AppDatabase _db;
UsersApiV2(
this._userManager,
this._paymentManager,
this._statisticsCalculator,
this._db,
);
Response _json(
@ -102,17 +102,24 @@ class UsersApiV2 {
final name = payload['name'] as String?;
final email = payload['email'] as String?;
await backend_main.isar.writeTxn(() async {
final current = await backend_main.isar.userModels.get(user.id!);
if (current == null) {
throw StateError('User not found');
}
final updated = current.copyWith(
name: name ?? current.name,
email: email ?? current.email,
if (user.id == null) {
return _json(
{
'error': 'bad_request',
'message': 'User ID is required',
},
statusCode: 400,
);
await backend_main.isar.userModels.put(updated);
});
}
await _db.userDao.updateUserPartial(
UsersCompanion(
id: drift.Value(user.id!),
name: name != null ? drift.Value(name) : const drift.Value.absent(),
email: email != null ? drift.Value(email) : const drift.Value.absent(),
updatedAt: drift.Value(DateTime.now()),
),
);
final refreshedUser = (await _userManager.fetchUser(user.id!))!;
final dto = await refreshedUser.toDto();
@ -209,8 +216,7 @@ class UsersApiV2 {
final payments = await _paymentManager.getUserPayments(
user.id.toString(),
);
final dtos = payments.map((payment) => payment.toDto()).toList();
return _json({'payments': dtos.map((p) => p.toJson()).toList()});
return _json({'payments': payments.map((p) => p.toJson()).toList()});
}
/// GET /api/v2/users/me/statistics/detailed
@ -223,13 +229,30 @@ class UsersApiV2 {
}
try {
final userData = user.userData.value;
if (user.id == null) {
return _json({'error': 'user_id_not_found'}, statusCode: 400);
}
final userData = await _db.userDao.getUserData(user.id!);
if (userData == null) {
return _json({'error': 'user_data_not_found'}, statusCode: 404);
}
final dto = userData.toDto();
return _json(dto.toJson());
// Convert UserData to UserDataDto
final userDataModel = await _db.userDao.getUserWithDataById(user.id!);
if (userDataModel?.userData == null) {
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
return _json({
'totalStudyTimeMinutes': userData.totalStudyTimeMinutes,
'currentStreak': userData.currentStreak,
'longestStreak': userData.longestStreak,
'studyDates': (userData.studyDates ?? []).map((d) => d.toIso8601String()).toList(),
});
} catch (e) {
return _json(
{'error': 'internal_server_error', 'message': e.toString()},
@ -458,20 +481,25 @@ class UsersApiV2 {
jsonDecode(body) as Map<String, dynamic>,
);
await backend_main.isar.writeTxn(() async {
final current = await backend_main.isar.userModels.get(user.id!);
if (current == null) {
throw StateError('User not found');
}
if (user.id == null) {
return _json(
{
'error': 'bad_request',
'message': 'User ID is required',
},
statusCode: 400,
);
}
final userData = current.userData.value;
await _db.transaction(() async {
final userData = await _db.userDao.getUserData(user.id!);
if (userData == null) {
throw StateError('User data not found');
}
// Update study dates
final now = DateTime.now();
final studyDates = List<DateTime>.from(userData.studyDates);
final studyDates = List<DateTime>.from(userData.studyDates ?? []);
studyDates.add(now);
// Update total study time
@ -483,18 +511,17 @@ class UsersApiV2 {
final longestStreak = max(userData.longestStreak, currentStreak);
// Update user data
final updatedUserData = userData.copyWith(
studyDates: studyDates,
totalStudyTimeMinutes: totalStudyTime,
currentStreak: currentStreak,
longestStreak: longestStreak,
lastTimeOnline: now,
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),
lastTimeOnline: drift.Value(now),
updatedAt: drift.Value(now),
),
);
// Save updated user data
await backend_main.isar.userDataModels.put(updatedUserData);
current.userData.value = updatedUserData;
await current.userData.save();
});
return _json({'result': true, 'sessionId': sessionDto.sessionId});

View file

@ -1,14 +1,14 @@
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/cron/task.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/packs/free_packs_distributor.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import '../main.dart';
import 'task.dart' as task;
class AddFreePacks with Task {
class AddFreePacks with task.Task {
final FreePacksDistributor _freePacksDistributor;
final AppDatabase _db;
AddFreePacks(this._freePacksDistributor);
AddFreePacks(this._freePacksDistributor, this._db);
@override
String get name => 'add_free_packs';
@ -23,23 +23,39 @@ class AddFreePacks with Task {
return;
}
final users = await isar.txn(() => isar.userModels
.filter()
.anyOf(freePacks, (u, f) => u.not().packs((p) => p.idEqualTo(f.id)))
.findAll());
final freePackIds = freePacks.map((p) => p.id).toSet();
// Получаем всех пользователей
final allUsers = await _db.userDao.getAllUsers();
// Фильтруем пользователей, у которых нет хотя бы одного из freePacks
final usersToUpdate = <int>[];
for (final user in allUsers) {
final userPacks = await _db.userDao.getUserPacks(user.id);
final userPackIds = userPacks.map((p) => p.id).toSet();
// Если у пользователя нет хотя бы одного из freePacks
if (!freePackIds.every((packId) => userPackIds.contains(packId))) {
usersToUpdate.add(user.id);
}
}
print(
'Found ${users.length} users for ${freePacks.length} free packs: ${freePacks.map((e) => e.title).join(',')}',
'Found ${usersToUpdate.length} users for ${freePacks.length} free packs: ${freePacks.map((e) => e.title).join(',')}',
);
for (final user in users) {
// Добавляем freePacks пользователям
for (final userId in usersToUpdate) {
try {
await isar.writeTxn(
() async {
await (user.packs..addAll(freePacks)).save();
},
);
for (final pack in freePacks) {
await _db.userDao.grantPackAccess(
userId: userId,
packId: pack.id,
grantType: 'free',
);
}
} catch (e) {
print('$name on user ${user.id}: $e');
print('$name on user $userId: $e');
}
}
}

View file

@ -27,8 +27,8 @@ class Backup with Task {
final to = '${backupDir}$filename';
final dir = Directory(backupDir)..createSync(recursive: true);
await _deleteOldBackups(dir);
await isar.copyToFile(to);
print('$name isar saved to $to');
// await database.copyToFile(to);
// print('$name isar saved to $to');
}
Future<void> _deleteOldBackups(Directory dir) async {

View file

@ -1,14 +1,17 @@
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/cron/task.dart';
import 'package:drift/drift.dart';
import 'package:mnemo_cards_backend/user/admin_ids_service.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import '../main.dart';
import '../database/database.dart';
import 'task.dart' as task;
class CheckAdminsTask with Task {
class CheckAdminsTask with task.Task {
@override
String get name => 'check_admins';
final AppDatabase _db;
CheckAdminsTask(this._db);
@override
Future<void> task() async {
// Получаем список ID админов из переменной окружения или файла
@ -18,36 +21,35 @@ class CheckAdminsTask with Task {
}
List<int> adminIds = [];
for (final token in adminIdsList) {
print('$name processing admin ID: $token');
if (token.isNotEmpty) {
await isar.writeTxn(() async {
final tokenModel = await isar.tokenModels
.filter()
.externalUserIdEqualTo(token)
.findFirst();
if (tokenModel == null) {
return;
for (final externalUserId in adminIdsList) {
print('$name processing admin ID: $externalUserId');
if (externalUserId.isNotEmpty) {
// Найти токен по externalUserId
final token = await _db.userDao.getTokenByExternalUserId(externalUserId);
if (token == null) {
continue;
}
// Получить пользователя по userId из токена
final user = await _db.userDao.getUserById(token.userId);
if (user != null) {
adminIds.add(user.id);
if (!user.admin) {
// Установить admin = true
await _db.userDao.updateUserPartial(
UsersCompanion(
id: Value(user.id),
admin: const Value(true),
updatedAt: Value(DateTime.now()),
),
);
}
final user = await isar.userModels.get(tokenModel.userId);
if (user != null) {
adminIds.add(user.id!);
if (!user.admin) {
await isar.userModels.put(user.copyWith.admin(true));
}
}
});
}
}
}
// isar.writeTxn(() async {
// final admins =
// await isar.userModels.filter().adminEqualTo(true).findAll();
// final notAdmins = admins
// .where((u) => !adminIds.contains(u.id))
// .map((u) => u.copyWith.admin(false))
// .toList();
// await isar.userModels.putAll(notAdmins);
// });
// TODO: Implement logic to remove admin flag from users not in adminIdsList
// if needed in the future
}
@override

View file

@ -1,45 +1,51 @@
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
import 'package:mnemo_cards_backend/cron/task.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import '../main.dart';
import 'task.dart' as task;
@LazySingleton()
class CheckPaymentTask with Task {
class CheckPaymentTask with task.Task {
final PaymentManager _paymentManager;
final AppDatabase _db;
CheckPaymentTask(
this._paymentManager,
this._db,
);
Future<void> task() => check();
Future<void> check() async {
print('Checking payments...');
final models = await isar.txn(
() async => await isar.paymentModels
.filter()
.not()
.statusEqualTo(PaymentStatus.processed)
.and()
.not()
.statusEqualTo(PaymentStatus.unknown)
.and()
.group(
(q) => q
.paymentSystemEqualTo(PaymentSystem.yookassa)
.or()
.paymentSystemEqualTo(PaymentSystem.rustore),
)
.findAll(),
);
print('Found ${models.length} unprocessed payments');
for (final model in models) {
// Получаем платежи, которые не обработаны и не unknown
final allPayments = await _db.paymentDao.getAllPayments();
final unprocessedPayments = allPayments.where((payment) {
final status = PaymentStatus.values.firstWhere(
(s) => s.name == payment.status,
orElse: () => PaymentStatus.unknown,
);
if (status == PaymentStatus.processed || status == PaymentStatus.unknown) {
return false;
}
final system = PaymentSystem.values.firstWhere(
(s) => s.name == payment.paymentSystem,
orElse: () => PaymentSystem.yookassa,
);
return system == PaymentSystem.yookassa || system == PaymentSystem.rustore;
}).toList();
print('Found ${unprocessedPayments.length} unprocessed payments');
for (final payment in unprocessedPayments) {
try {
await _paymentManager.checkAndProcessPayment(model);
// PaymentManager должен работать с Payment (Drift)
// TODO: Возможно нужна конвертация в PaymentModel если PaymentManager еще не мигрирован
await _paymentManager.checkAndProcessPayment(payment);
} catch (e, s) {
print(e);
print(s);

View file

@ -15,38 +15,39 @@ class DeleteOldArchives with Task {
@override
Future<void> task() async {
final archives =
(await Directory('${PackManager.assetsDirectory.path}/pack_archives')
.list(recursive: true)
.toList())
.whereType<File>();
final packs = await isar.cardPackModels.where().findAll();
final toDelete = <File>[];
for (final pack in packs) {
try {
for (final archive in archives) {
final names = basename(archive.path).split('_');
if (names.first == pack.id?.toString() &&
names.last != pack.version) {
toDelete.add(archive);
}
}
} catch (e) {
print('$name on pack ${pack.id}: $e');
}
}
try {
for (final file in toDelete) {
await file.delete();
}
} catch (e, s) {
log(
'Error when deleting packs',
error: e,
stackTrace: s,
);
}
print('deleted ${toDelete.length} old archives');
return;
// final archives =
// (await Directory('${PackManager.assetsDirectory.path}/pack_archives')
// .list(recursive: true)
// .toList())
// .whereType<File>();
// final packs = await isar.cardPackModels.where().findAll();
// final toDelete = <File>[];
// for (final pack in packs) {
// try {
// for (final archive in archives) {
// final names = basename(archive.path).split('_');
// if (names.first == pack.id?.toString() &&
// names.last != pack.version) {
// toDelete.add(archive);
// }
// }
// } catch (e) {
// print('$name on pack ${pack.id}: $e');
// }
// }
// try {
// for (final file in toDelete) {
// await file.delete();
// }
// } catch (e, s) {
// log(
// 'Error when deleting packs',
// error: e,
// stackTrace: s,
// );
// }
// print('deleted ${toDelete.length} old archives');
}
@override

View file

@ -1,44 +1,26 @@
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/cron/task.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import '../discounts/discounts_manager.dart';
import '../main.dart';
import 'task.dart' as task;
class DiscountCampaignTask with Task {
class DiscountCampaignTask with task.Task {
final DiscountsManager _discountsManager;
final AppDatabase _db;
DiscountCampaignTask(this._discountsManager);
DiscountCampaignTask(this._discountsManager, this._db);
@override
String get name => 'discount_campaign';
@override
Future<void> task() async {
final campaigns = await isar.txn(() async {
return await isar.discountCampaignModels
.filter()
.statusEqualTo(DiscountCampaignModelStatus.created)
.or()
.statusEqualTo(DiscountCampaignModelStatus.active)
.findAll();
});
final now = DateTime.now();
final campaigns = await _db.discountDao.getCampaignsByStatuses([
'created',
'active',
]);
for (final campaign in campaigns) {
switch (campaign.status) {
case DiscountCampaignModelStatus.created:
_discountsManager.changeCampaignStatus(campaign);
break;
case DiscountCampaignModelStatus.active:
_discountsManager.changeCampaignStatus(campaign);
break;
case DiscountCampaignModelStatus.expired:
// Ignoring
break;
case DiscountCampaignModelStatus.disabled:
// Ignoring
break;
}
await _discountsManager.changeCampaignStatus(campaign);
}
}

View file

@ -1,28 +1,26 @@
import 'dart:developer';
import 'dart:math' as math;
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/cron/task.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:drift/drift.dart' as drift;
import 'package:mnemo_cards_backend/cron/task.dart' as task;
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/database/database.dart' as drift;
import '../main.dart';
class GeneratePromocodes with Task {
class GeneratePromocodes with task.Task {
final AppDatabase _db;
final random = math.Random.secure();
GeneratePromocodes(this._db);
@override
String get name => 'generate_promocodes';
@override
Future<void> task() async {
final campaigns = await isar.txn(() async {
return await isar.promoCodesCampaignModels
.filter()
.statusEqualTo(PromoCodeCampaignModelStatus.created)
.or()
.statusEqualTo(PromoCodeCampaignModelStatus.preparing)
.findAll();
});
final campaigns = await _db.promoCodeDao.getCampaignsByStatuses([
'created',
'preparing',
]);
await Future.wait([
for (final campaign in campaigns) _generateCampaignPromoCodes(campaign),
]);
@ -44,45 +42,54 @@ class GeneratePromocodes with Task {
.replaceAllMapped(r'$s', (_) => randomSymbol);
Future<void> _generateCampaignPromoCodes(
PromoCodesCampaignModel campaign,
PromoCodesCampaign campaign,
) async {
print('Generating promo codes for campaign ${campaign.id}');
try {
if (campaign.status != PromoCodeCampaignModelStatus.preparing) {
await isar.writeTxn(() async {
isar.promoCodesCampaignModels.put(campaign.copyWith(
status: PromoCodeCampaignModelStatus.preparing));
});
if (campaign.status != 'preparing') {
await _db.promoCodeDao.updateCampaignStatus(campaign.id, 'preparing');
}
final PAGE_INITIAL = 50;
int page = PAGE_INITIAL;
int leftToGenerate = campaign.generationSize - campaign.promoCodes.length;
while (leftToGenerate > 0) {
while (true) {
// Получаем текущее количество промокодов
final existingCodes = await _db.promoCodeDao.getPromoCodesByCampaignId(campaign.id);
final currentCount = existingCodes.length;
final leftToGenerate = campaign.generationSize - currentCount;
if (leftToGenerate <= 0) {
break;
}
final willGenerate = math.min(leftToGenerate, page);
try {
await isar.writeTxn(() async {
final promoCodes = <PromoCodeModel>[];
await _db.transaction(() async {
final promoCodes = <drift.PromoCodesCompanion>[];
for (int i = 0; i < willGenerate; i++) {
promoCodes.add(
PromoCodeModel(
drift.PromoCodesCompanion.insert(
campaignId: campaign.id,
code: _generateCode(campaign.template),
activations: 0,
)..campaign.value = campaign,
activations: const drift.Value(0),
),
);
}
await isar.promoCodeModels.putAll(promoCodes);
await campaign.promoCodes.load();
campaign.promoCodes.addAll(promoCodes);
await campaign.promoCodes.save();
await _db.promoCodeDao.createPromoCodes(promoCodes);
});
if (page < PAGE_INITIAL) {
page *= 2;
}
} on Object catch (e) {
if (e is IsarError &&
page > 1 &&
e.message.contains('Unique index violated')) {
} catch (e) {
// Обработка уникальных нарушений (duplicate key)
final errorMessage = e.toString().toLowerCase();
if (page > 1 &&
(errorMessage.contains('unique') ||
errorMessage.contains('duplicate') ||
errorMessage.contains('violates unique constraint'))) {
page ~/= 2;
print(
'UniqueViolationError when generating codes for ${campaign.id}, retrying with page: $page',
@ -92,28 +99,18 @@ class GeneratePromocodes with Task {
rethrow;
}
}
leftToGenerate = campaign.generationSize - campaign.promoCodes.length;
}
final loadedCampaign =
await isar.promoCodesCampaignModels.get(campaign.id!);
if (loadedCampaign == null) {
log('Campaign not found in db: ${campaign.id}');
return;
}
if (loadedCampaign.promoCodes.length >= campaign.generationSize) {
await isar.writeTxn(
() async {
isar.promoCodesCampaignModels.put(
loadedCampaign.copyWith(
status: PromoCodeCampaignModelStatus.ready,
),
);
},
);
// Проверяем, достигли ли мы нужного количества
final finalCodes = await _db.promoCodeDao.getPromoCodesByCampaignId(campaign.id);
if (finalCodes.length >= campaign.generationSize) {
await _db.promoCodeDao.updateCampaignStatus(campaign.id, 'ready');
}
print('Finished generating promo codes for campaign ${campaign.id}');
} catch (e, s) {
print('Error when generating promo codes for ${campaign.id} $e $s');
log('Error when generating promo codes for ${campaign.id}', error: e, stackTrace: s);
}
}

View file

@ -1,10 +1,13 @@
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/cron/task.dart';
import 'package:drift/drift.dart';
import 'package:mnemo_cards_backend/cron/task.dart' as cron_task;
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import '../main.dart';
class TasksSeederTask with cron_task.Task {
final AppDatabase _db;
TasksSeederTask(this._db);
class TasksSeederTask with Task {
@override
String get name => 'tasks_seeder';
@ -14,7 +17,7 @@ class TasksSeederTask with Task {
try {
// Check if tasks already exist
final existingTasks = await isar.userTaskModels.where().findAll();
final existingTasks = await _db.taskDao.getAllUserTasks();
if (existingTasks.isNotEmpty) {
print('Tasks already exist, skipping seeding');
return;
@ -199,11 +202,27 @@ class TasksSeederTask with Task {
),
];
await isar.writeTxn(() async {
for (final task in tasks) {
await isar.userTaskModels.put(task);
}
});
// Convert UserTaskModel to UserTasksCompanion and insert
for (final taskModel in tasks) {
final rewardsJson = taskModel.rewards.map((r) => r.toJson()).toList();
await _db.taskDao.createUserTask(
UserTasksCompanion.insert(
title: taskModel.title,
description: taskModel.description,
type: taskModel.type,
difficulty: taskModel.difficulty,
status: taskModel.status,
rewards: Value(rewardsJson),
createdAt: Value(taskModel.createdAt),
expiresAt: taskModel.expiresAt,
completedAt: Value(taskModel.completedAt),
proofUrl: Value(taskModel.proofUrl),
instructions: Value(taskModel.instructions),
tags: Value(taskModel.tags),
imageUrl: Value(taskModel.imageUrl),
),
);
}
print('Successfully seeded ${tasks.length} user tasks');
} catch (e) {

View file

@ -1,14 +1,13 @@
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/cron/task.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/cron/task.dart' as cron_task;
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/tests/test_manager.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import '../main.dart';
class TestGeneratorTask with Task {
class TestGeneratorTask with cron_task.Task {
final TestManager _testManager;
final AppDatabase _db;
TestGeneratorTask(this._testManager);
TestGeneratorTask(this._testManager, this._db);
@override
String get name => 'test_gen';
@ -22,12 +21,31 @@ class TestGeneratorTask with Task {
print(e);
}
print('Refreshed custom test data');
final models = await isar.txn(() => isar.cardPackModels.where().findAll());
print('Will generate tests for ${models.length} packs');
final packs = await _db.packDao.getAllPacks();
print('Will generate tests for ${packs.length} packs');
int ok = 0;
for (final model in models) {
for (final pack in packs) {
try {
await _testManager.updateGeneratedTests(model);
// Convert CardPack to CardPackModel for updateGeneratedTests
final packModel = CardPackModel(
id: pack.id,
title: pack.title,
subtitle: pack.subtitle,
description: pack.description,
color: pack.color,
cover: pack.cover,
size: pack.size,
version: pack.version,
order: pack.order,
enabled: pack.enabled,
cardsOrder: pack.cardsOrder,
googlePlayId: pack.googlePlayId,
rustoreId: pack.rustoreId,
appStoreId: pack.appStoreId,
price: pack.price,
currency: pack.currency,
);
await _testManager.updateGeneratedTests(packModel);
ok++;
} catch (e) {
print(e);
@ -35,9 +53,23 @@ class TestGeneratorTask with Task {
}
print('Generated tests for $ok packs');
print('Deleting old test stats');
final count = await isar.writeTxn(() {
return isar.testStatisticsModels.filter().testIsNull().deleteAll();
});
// Delete test statistics where test doesn't exist (test was deleted)
final allTests = await _db.testDao.getAllTests();
final testIds = allTests.map((t) => t.id).toSet();
if (testIds.isEmpty) {
// If no tests exist, delete all statistics
final count = await (_db.delete(_db.testStatistics)).go();
print('deleted $count old test stats');
return;
}
// Get all statistics and filter in memory (more efficient for small datasets)
final allStats = await (_db.select(_db.testStatistics)).get();
final statsToDelete = allStats.where((stat) => !testIds.contains(stat.testId)).toList();
int count = 0;
for (final stat in statsToDelete) {
await (_db.delete(_db.testStatistics)..where((ts) => ts.id.equals(stat.id))).go();
count++;
}
print('deleted $count old test stats');
}

View file

@ -39,13 +39,14 @@ class AchievementDao extends DatabaseAccessor<AppDatabase> with _$AchievementDao
}
/// Обновить прогресс достижения
Future<bool> updateAchievementProgress(int userId, String achievementId, double progress) {
return (update(userAchievements)
Future<bool> updateAchievementProgress(int userId, String achievementId, double progress) async {
final count = await (update(userAchievements)
..where((ua) => ua.userId.equals(userId) & ua.achievementId.equals(achievementId))
).write(UserAchievementsCompanion(
progress: Value(progress),
updatedAt: Value(DateTime.now()),
));
return count > 0;
}
/// Получить прогресс по всем достижениям пользователя
@ -58,7 +59,7 @@ class AchievementDao extends DatabaseAccessor<AppDatabase> with _$AchievementDao
/// Удалить достижение пользователя (для сброса)
Future<void> removeAchievement(int userId, String achievementId) {
(delete(userAchievements)
return (delete(userAchievements)
..where((ua) => ua.userId.equals(userId) & ua.achievementId.equals(achievementId))
).go();
}

View file

@ -37,6 +37,49 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
return update(db.discountCampaigns).replace(campaign);
}
/// Обновить статус кампании
Future<void> updateCampaignStatus(int campaignId, String status) {
return (update(db.discountCampaigns)
..where((c) => c.id.equals(campaignId))
).write(DiscountCampaignsCompanion(
status: Value(status),
updatedAt: Value(DateTime.now()),
));
}
/// Получить кампании по статусу
Future<List<DiscountCampaign>> getCampaignsByStatus(String status) {
return (select(db.discountCampaigns)
..where((c) => c.status.equals(status))
..where((c) => c.isDeleted.equals(false))
).get();
}
/// Получить кампании по статусам (несколько)
Future<List<DiscountCampaign>> getCampaignsByStatuses(List<String> statuses) {
return (select(db.discountCampaigns)
..where((c) => c.status.isIn(statuses))
..where((c) => c.isDeleted.equals(false))
).get();
}
/// Получить все кампании
Future<List<DiscountCampaign>> getAllCampaigns() {
return (select(db.discountCampaigns)
..where((c) => c.isDeleted.equals(false))
).get();
}
/// Удалить кампанию (soft delete)
Future<void> deleteCampaign(int campaignId) {
return (update(db.discountCampaigns)
..where((c) => c.id.equals(campaignId))
).write(DiscountCampaignsCompanion(
isDeleted: const Value(true),
updatedAt: Value(DateTime.now()),
));
}
// ==================== Discounts ====================
/// Получить скидку по ID
@ -95,6 +138,22 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
).go();
}
/// Отозвать несколько скидок у пользователя
Future<void> revokeDiscountsFromUser(int userId, List<int> discountIds) async {
if (discountIds.isEmpty) return;
await (delete(db.discountUserDatas)
..where((dud) => dud.userId.equals(userId) & dud.discountId.isIn(discountIds))
).go();
}
/// Дать пользователю доступ к нескольким скидкам
Future<void> grantDiscountsToUser(int userId, List<int> discountIds) async {
if (discountIds.isEmpty) return;
await Future.wait(
discountIds.map((discountId) => grantDiscountToUser(userId, discountId))
);
}
/// Проверить, есть ли у пользователя доступ к скидке
Future<bool> hasDiscountAccess(int userId, int discountId) async {
final query = select(db.discountUserDatas)
@ -103,4 +162,52 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
final result = await query.getSingleOrNull();
return result != null;
}
/// Получить активные кампании с учетом тегов и продуктов
Future<List<DiscountCampaign>> getActiveCampaignsForUser({
required List<String> userTags,
String? productType,
String? productId,
}) async {
final now = DateTime.now();
final query = select(db.discountCampaigns)
..where((c) => c.status.equals('active'))
..where((c) => c.start.isSmallerOrEqualValue(now))
..where((c) => c.finish.isBiggerOrEqualValue(now))
..where((c) => c.isDeleted.equals(false));
var campaigns = await query.get();
// Фильтруем по тегам если они есть
if (userTags.isNotEmpty) {
campaigns = campaigns.where((campaign) {
final campaignTags = campaign.tags.toSet();
return campaignTags.isEmpty || campaignTags.intersection(userTags.toSet()).isNotEmpty;
}).toList();
}
// Фильтруем по продукту если указан
if (productType != null || productId != null) {
// Нужно загрузить скидки для каждой кампании и проверить продукты
final filteredCampaigns = <DiscountCampaign>[];
for (final campaign in campaigns) {
final discounts = await getDiscountsByCampaignId(campaign.id);
final hasMatchingProduct = discounts.any((discount) {
final products = discount.products ?? [];
return products.any((product) {
if (product is! Map<String, dynamic>) return false;
if (productType != null && product['type'] != productType) return false;
if (productId != null && product['id'] != productId) return false;
return true;
});
});
if (hasMatchingProduct) {
filteredCampaigns.add(campaign);
}
}
campaigns = filteredCampaigns;
}
return campaigns;
}
}

View file

@ -92,11 +92,26 @@ class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
}
/// Получить все карточки пака
Future<List<GameCard>> getPackCards(int packId) {
return (select(gameCards)
..where((c) => c.packId.equals(packId))
..orderBy([(c) => OrderingTerm.asc(c.id)]) // TODO: use proper ordering
).get();
Future<List<GameCard>> getPackCards(int packId) async {
final query = select(gameCards).join([
leftOuterJoin(
cardPackCards,
cardPackCards.cardId.equalsExp(gameCards.id) &
cardPackCards.packId.equals(packId),
),
])..where(gameCards.packId.equals(packId));
final results = await query.get();
// Сортируем по order из junction table, если есть, иначе по ID
results.sort((a, b) {
final orderA = a.read(cardPackCards.order) ?? 999999;
final orderB = a.read(cardPackCards.order) ?? 999999;
if (orderA != orderB) return orderA.compareTo(orderB);
return a.readTable(gameCards).id.compareTo(b.readTable(gameCards).id);
});
return results.map((row) => row.readTable(gameCards)).toList();
}
/// Получить карточки по списку ID
@ -275,20 +290,4 @@ class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
Future<VoiceModel?> getVoiceById(int id) {
return (select(voiceModels)..where((v) => v.id.equals(id))).getSingleOrNull();
}
/// Подсчитать все паки
Future<int> countPacks() async {
final countExpr = cardPacks.id.count();
final query = selectOnly(cardPacks)..addColumns([countExpr]);
return await query.map((row) => row.read(countExpr)!).getSingle();
}
/// Подсчитать все карточки
Future<int> countCards() async {
final countExpr = gameCards.id.count();
final query = selectOnly(gameCards)..addColumns([countExpr]);
return await query.map((row) => row.read(countExpr)!).getSingle();
}
}

View file

@ -37,6 +37,34 @@ class PromoCodeDao extends DatabaseAccessor<AppDatabase> with _$PromoCodeDaoMixi
return update(db.promoCodesCampaigns).replace(campaign);
}
/// Обновить статус кампании
Future<void> updateCampaignStatus(int campaignId, String status) {
return (update(db.promoCodesCampaigns)
..where((c) => c.id.equals(campaignId))
).write(PromoCodesCampaignsCompanion(
status: Value(status),
updatedAt: Value(DateTime.now()),
));
}
/// Получить кампании по статусам
Future<List<PromoCodesCampaign>> getCampaignsByStatuses(List<String> statuses) {
return (select(db.promoCodesCampaigns)
..where((c) => c.status.isIn(statuses))
..where((c) => c.isDeleted.equals(false))
).get();
}
/// Создать несколько промокодов
Future<List<int>> createPromoCodes(List<PromoCodesCompanion> promoCodes) async {
final ids = <int>[];
for (final promoCode in promoCodes) {
final id = await createPromoCode(promoCode);
ids.add(id);
}
return ids;
}
// ==================== PromoCodes ====================
/// Получить промокод по коду

View file

@ -86,4 +86,21 @@ class SubscriptionDao extends DatabaseAccessor<AppDatabase> with _$SubscriptionD
..orderBy([(us) => OrderingTerm.desc(us.finish)])
).get();
}
/// Получить активную подписку пользователя (alias для getActiveSubscription)
Future<UserSubscription?> getActiveUserSubscription(int userId) {
return getActiveSubscription(userId);
}
/// Отменить подписку пользователя (установить finish на текущее время)
Future<void> cancelUserSubscription(int userId) async {
final subscription = await getActiveSubscription(userId);
if (subscription != null) {
await update(userSubscriptions).replace(
subscription.copyWith(
finish: DateTime.now(),
),
);
}
}
}

View file

@ -47,15 +47,16 @@ class TaskDao extends DatabaseAccessor<AppDatabase> with _$TaskDaoMixin {
return (select(db.userTasks)..where((ut) => ut.id.equals(id))).getSingleOrNull();
}
/// Получить все задачи пользователей (для системных задач)
Future<List<UserTask>> getAllUserTasks() {
return select(db.userTasks).get();
}
/// Получить задачи пользователя
/// Note: UserTasks table doesn't have userId column directly
/// This method needs to be implemented based on actual schema
Future<List<UserTask>> getUserTasks(int userId, {
String? status,
bool activeOnly = false,
}) {
// TODO: Implement based on actual UserTasks schema
// For now, return all tasks
final query = select(db.userTasks);
if (status != null) {
@ -91,6 +92,18 @@ class TaskDao extends DatabaseAccessor<AppDatabase> with _$TaskDaoMixin {
updatedAt: Value(DateTime.now()),
));
}
/// Подсчитать задачи пользователя
Future<int> countUserTasks(int userId, {String? status}) async {
final countExpr = db.userTasks.id.count();
final query = selectOnly(db.userTasks)..addColumns([countExpr]);
if (status != null) {
query.where(db.userTasks.status.equals(status));
}
return await query.map((row) => row.read(countExpr)!).getSingle();
}
// ==================== UserTaskProgresses ====================

View file

@ -113,10 +113,19 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
/// Получить пользователей с активной подпиской
Stream<List<User>> watchUsersWithActiveSubscription() {
// TODO: implement with join to UserSubscriptions when SubscriptionDao is ready
return (select(users)
..where((u) => u.isDeleted.equals(false))
).watch();
final now = DateTime.now();
final query = select(users).join([
innerJoin(
db.userSubscriptions,
db.userSubscriptions.userId.equalsExp(users.id) &
db.userSubscriptions.start.isSmallerThanValue(now) &
db.userSubscriptions.finish.isBiggerThanValue(now),
),
])..where(users.isDeleted.equals(false));
return query.watch().map((rows) =>
rows.map((row) => row.readTable(users)).toList()
);
}
// ==================== UserData ====================
@ -163,6 +172,15 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
.getSingleOrNull();
}
/// Получить токен по externalUserId
Future<Token?> getTokenByExternalUserId(String externalUserId) {
return (select(tokens)
..where((t) => t.externalUserId.equals(externalUserId))
..orderBy([(t) => OrderingTerm.desc(t.created)])
..limit(1)
).getSingleOrNull();
}
/// Получить токен пользователя
Future<Token?> getTokenByUserId(int userId) {
return (select(tokens)

View file

@ -0,0 +1,133 @@
import 'package:drift/drift.dart' as drift;
import 'package:mnemo_cards_backend/database/daos/discount_dao.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
/// Extension для конвертации DiscountCampaign (Drift) в DTO
extension DiscountCampaignToDto on DiscountCampaign {
Future<DiscountCampaignDto> toDto(DiscountDao discountDao) async {
final discounts = await discountDao.getDiscountsByCampaignId(id);
final discountsDto = discounts.map((d) => d.toDto()).toList();
DiscountCampaignStatus statusEnum;
switch (status) {
case 'created':
statusEnum = DiscountCampaignStatus.created;
break;
case 'active':
statusEnum = DiscountCampaignStatus.active;
break;
case 'expired':
statusEnum = DiscountCampaignStatus.expired;
break;
case 'disabled':
statusEnum = DiscountCampaignStatus.disabled;
break;
default:
statusEnum = DiscountCampaignStatus.created;
}
return DiscountCampaignDto(
id: id,
start: start,
finish: finish,
status: statusEnum,
name: name,
tags: tags,
discounts: discountsDto,
);
}
}
/// Extension для конвертации Discount (Drift) в DTO
extension DiscountToDto on Discount {
DiscountDto toDto() {
final productsList = (products ?? []).map((p) {
if (p is Map<String, dynamic>) {
return MnemoCardsProductDto.fromJson(p);
}
return null;
}).whereType<MnemoCardsProductDto>().toList();
return DiscountDto(
discountPercent: discountPercent,
products: productsList,
id: id.toString(),
);
}
}
/// Extension для конвертации DiscountCampaignDto в DiscountCampaignsCompanion
extension DiscountCampaignDtoToCompanion on DiscountCampaignDto {
DiscountCampaignsCompanion toCompanion() {
String statusStr;
switch (status) {
case DiscountCampaignStatus.created:
statusStr = 'created';
break;
case DiscountCampaignStatus.active:
statusStr = 'active';
break;
case DiscountCampaignStatus.expired:
statusStr = 'expired';
break;
case DiscountCampaignStatus.disabled:
statusStr = 'disabled';
break;
}
return DiscountCampaignsCompanion.insert(
id: id != null ? drift.Value(id!) : const drift.Value.absent(),
name: drift.Value(name),
start: start,
finish: finish,
status: statusStr,
tags: drift.Value(tags),
);
}
}
/// Extension для конвертации DiscountDto в DiscountsCompanion
extension DiscountDtoToCompanion on DiscountDto {
DiscountsCompanion toCompanion(int campaignId) {
final productsJson = products.map((p) => p.toJson()).toList();
return DiscountsCompanion.insert(
campaignId: campaignId,
discountPercent: discountPercent,
products: drift.Value(productsJson),
);
}
}
/// Helper для конвертации статуса в строку
String discountCampaignStatusToString(DiscountCampaignModelStatus status) {
switch (status) {
case DiscountCampaignModelStatus.created:
return 'created';
case DiscountCampaignModelStatus.active:
return 'active';
case DiscountCampaignModelStatus.expired:
return 'expired';
case DiscountCampaignModelStatus.disabled:
return 'disabled';
}
}
/// Helper для конвертации строки в статус
DiscountCampaignModelStatus? discountCampaignStatusFromString(String? status) {
if (status == null) return null;
switch (status) {
case 'created':
return DiscountCampaignModelStatus.created;
case 'active':
return DiscountCampaignModelStatus.active;
case 'expired':
return DiscountCampaignModelStatus.expired;
case 'disabled':
return DiscountCampaignModelStatus.disabled;
default:
return null;
}
}

View file

@ -1,48 +1,63 @@
import 'dart:developer';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/discounts/discount_campaign_ext.dart';
import 'package:mnemo_cards_backend/database/daos/discount_dao.dart';
import 'package:mnemo_cards_backend/database/daos/user_dao.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/discounts/discount_drift_extension.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import '../main.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart' hide DateTimeExt;
import 'package:mnemo_cards_common/src/utils/utils.dart';
@lazySingleton
class DiscountsManager {
final AppDatabase _db;
final DiscountDao _discountDao;
final UserDao _userDao;
DiscountsManager(this._db)
: _discountDao = _db.discountDao,
_userDao = _db.userDao;
Future<void> applyDiscount({
required Iterable<DiscountModel> discounts,
required UserModel user,
}) async {
await isar.writeTxn(() async {
await user.userData.load();
final userData = user.userData.value!;
userData.activeDiscounts.addAll(discounts);
await user.userData.save();
});
if (user.id == null) return;
// Получаем ID скидок
final discountIds = discounts
.where((d) => d.id != null)
.map((d) => d.id as int)
.toList();
if (discountIds.isEmpty) return;
// Добавляем скидки пользователю через junction таблицу
await _discountDao.grantDiscountsToUser(user.id!, discountIds);
}
Future<void> changeCampaignStatus(DiscountCampaignModel campaign) async {
Future<void> changeCampaignStatus(DiscountCampaign campaign) async {
final now = DateTime.now();
var target = campaign.status;
if (campaign.status == DiscountCampaignModelStatus.created &&
String? targetStatus;
if (campaign.status == 'created' &&
now.isBetween(campaign.start, campaign.finish)) {
target = DiscountCampaignModelStatus.active;
} else if (campaign.status == DiscountCampaignModelStatus.active) {
targetStatus = 'active';
} else if (campaign.status == 'active') {
if (now.isBefore(campaign.start)) {
target = DiscountCampaignModelStatus.created;
targetStatus = 'created';
} else if (now.isAfter(campaign.finish)) {
target = DiscountCampaignModelStatus.expired;
targetStatus = 'expired';
}
}
if (target != campaign.status) {
await isar.writeTxn(() => isar.discountCampaignModels.put(
campaign.copyWith(status: target),
));
if (targetStatus != null && targetStatus != campaign.status) {
await _discountDao.updateCampaignStatus(campaign.id, targetStatus);
}
}
Future<DiscountError?> applyCampaign(DiscountCampaignModel campaign) async {
Future<DiscountError?> applyCampaign(DiscountCampaign campaign) async {
final now = DateTime.now();
if (!now.isBetween(campaign.start, campaign.finish)) {
if (now.isBefore(campaign.start)) {
@ -50,109 +65,101 @@ class DiscountsManager {
}
return DiscountError(DiscountErrorType.expired);
}
// final usersData = await isar.txn(
// () => isar.userDataModels
// .filter()
// .optional(
// campaign.tags.isNotEmpty,
// (q) => q.anyOf(
// campaign.tags,
// (q, tag) => q.tagsElementContains(tag),
// ),
// )
// .findAll(),
// );
// TODO: Implement applying campaign to users based on tags
// This would require querying UserDatas with matching tags
return null;
}
Future<void> removeDiscount({
required DiscountCampaignModel campaign,
required DiscountCampaign campaign,
required UserModel user,
}) async {
await isar.writeTxn(() async {
await user.userData.load();
final userData = user.userData.value!;
userData.activeDiscounts.removeAll(campaign.discounts);
await isar.userDataModels.put(userData);
});
if (user.id == null) return;
// Получаем скидки кампании
final discounts = await _discountDao.getDiscountsByCampaignId(campaign.id);
final discountIds = discounts.map((d) => d.id).toList();
if (discountIds.isEmpty) return;
// Удаляем скидки пользователя
await _discountDao.revokeDiscountsFromUser(user.id!, discountIds);
}
Future<double> getProductDiscount(
MnemoCardsProductModel model,
UserDataModel userData,
UserData userData,
) async {
double maxDiscount = 0;
for (final discount in userData.activeDiscounts) {
if (discount.discountPercent > maxDiscount &&
discount.products.any((p) => p.equalsProduct(model))) {
maxDiscount = discount.discountPercent;
// Получаем активные скидки пользователя из junction таблицы
final userDiscounts = await _discountDao.getUserDiscounts(userData.userId);
for (final discount in userDiscounts) {
final discountDto = discount.toDto();
if (discountDto.discountPercent > maxDiscount &&
discountDto.products.any((p) => model.equalsDto(p))) {
maxDiscount = discountDto.discountPercent;
}
}
final activeCampaigns =
await discountCampaignsForUser(userData, product: model);
// Получаем активные кампании
final user = await _userDao.getUserById(userData.userId);
if (user == null) return maxDiscount;
final userDataModel = await _userDao.getUserData(userData.userId);
final userTags = userDataModel?.tags ?? [];
final activeCampaigns = await _discountDao.getActiveCampaignsForUser(
userTags: userTags,
productType: model.type.name,
productId: model.id.toString(),
);
for (final campaign in activeCampaigns) {
for (final discount in campaign.discounts) {
if (discount.discountPercent > maxDiscount &&
discount.products.any((p) => p.equalsProduct(model))) {
maxDiscount = discount.discountPercent;
final discounts = await _discountDao.getDiscountsByCampaignId(campaign.id);
for (final discount in discounts) {
final discountDto = discount.toDto();
if (discountDto.discountPercent > maxDiscount &&
discountDto.products.any((p) => model.equalsDto(p))) {
maxDiscount = discountDto.discountPercent;
}
}
}
return maxDiscount;
}
Future<List<DiscountCampaignModel>> discountCampaignsForUser(
UserDataModel userModel, {
Future<List<DiscountCampaign>> discountCampaignsForUser(
UserData userData, {
MnemoCardsProductModel? product,
}) async {
final activeCampaigns =
await isar.txn(() async => isar.discountCampaignModels
.filter()
.statusEqualTo(DiscountCampaignModelStatus.active)
.optional(userModel.tags.isEmpty, (q) => q.tagsIsEmpty())
.optional(
product != null,
(q) => q.discounts(
(d) => d.productsElement(
(p) => p.typeEqualTo(product!.type).productIdEqualTo(
product.id,
),
),
),
)
.findAll());
if (userModel.tags.isEmpty) {
return activeCampaigns;
} else {
// todo check tags in query
final userTags = userModel.tags.toSet();
return activeCampaigns
.where((campaign) => campaign.tags.hasIntersection(userTags))
.toList();
}
final userTags = userData.tags;
return await _discountDao.getActiveCampaignsForUser(
userTags: userTags,
productType: product?.type.name,
productId: product?.id.toString(),
);
}
Future<List<DiscountCampaignDto>> discountCampaigns() async {
final models = await isar
.txn(() async => isar.discountCampaignModels.where().findAll());
final dtos = await Future.wait(models.map((m) async => await m.toDto()));
final campaigns = await _discountDao.getAllCampaigns();
final dtos = await Future.wait(
campaigns.map((c) async => await c.toDto(_discountDao)),
);
return dtos;
}
Future<String?> deleteDiscountCampaign(String stringId) async {
try {
final id = int.parse(stringId);
isar.writeTxn(() async {
final model = await isar.discountCampaignModels.get(id);
if (model == null) {
return 'Campaign $id not found';
}
if (model.status != DiscountCampaignModelStatus.disabled) {
return 'Disable campaign before deleting';
}
isar.discountCampaignModels.delete(id);
});
final campaign = await _discountDao.getCampaignById(id);
if (campaign == null) {
return 'Campaign $id not found';
}
if (campaign.status != 'disabled') {
return 'Disable campaign before deleting';
}
await _discountDao.deleteCampaign(id);
return null;
} catch (e, s) {
log('Error when deleting discount campaign', error: e, stackTrace: s);
@ -162,32 +169,28 @@ class DiscountsManager {
Future<bool> addDiscount(DiscountCampaignDto dto) async {
try {
final model = DiscountCampaignModel(
id: dto.id,
start: dto.start,
finish: dto.finish,
status: dto.status.toModel(),
name: dto.name,
tags: dto.tags,
);
final discounts = dto.discounts.map((d) => d.toModel()).toList();
await isar.writeTxn(() async {
await isar.discountModels.putAll(discounts);
await isar.discountCampaignModels.put(model);
await model.discounts.reset();
model.discounts.addAll(discounts);
await model.discounts.save();
});
return await _db.transaction(() async {
// Создаем кампанию
final campaignCompanion = dto.toCompanion();
final campaignId = await _discountDao.createCampaign(campaignCompanion);
// Создаем скидки
final discountCompanions = dto.discounts
.map((discountDto) => discountDto.toCompanion(campaignId))
.toList();
for (final discountCompanion in discountCompanions) {
await _discountDao.createDiscount(discountCompanion);
}
return true;
return true;
});
} catch (e, s) {
print('error when adding campaign');
log('error when adding campaign', error: e, stackTrace: s);
return false;
}
}
const DiscountsManager();
}
class DiscountError {

View file

@ -76,19 +76,19 @@ void main() async {
);
// For rustore payments
final rustoreEnabled = false;
if (rustoreEnabled) {
final pythonInit = await Process.run(
workingDirectory: WORK_DIR,
'./prepare_python.sh',
[],
runInShell: true,
);
print(pythonInit.stdout);
print(pythonInit.stderr);
} else {
log('\n\n*******\nRUSTORE IS DISABLED\n******\n\n', level: 1000);
}
// final rustoreEnabled = false;
// if (rustoreEnabled) {
// final pythonInit = await Process.run(
// workingDirectory: WORK_DIR,
// './prepare_python.sh',
// [],
// runInShell: true,
// );
// print(pythonInit.stdout);
// print(pythonInit.stderr);
// } else {
// log('\n\n*******\nRUSTORE IS DISABLED\n******\n\n', level: 1000);
// }
// Инициализация PostgreSQL
try {
@ -109,15 +109,14 @@ void main() async {
// ignore: unawaited_futures
CronManager([
DeleteOldArchives(),
CheckAdminsTask(),
// TestGeneratorTask(getIt.get<TestManager>()), // TODO: enable after TestManager migration
CheckAdminsTask(database),
getIt.get<CheckPaymentTask>(),
AddFreePacks(getIt.get<FreePacksDistributor>()),
AddFreePacks(getIt.get<FreePacksDistributor>(), database),
Backup(backupDir),
GeneratePromocodes(),
DiscountCampaignTask(getIt.get<DiscountsManager>()),
GeneratePromocodes(database),
DiscountCampaignTask(getIt.get<DiscountsManager>(), database),
UpdateOnlineUsersTask(getIt.get<UserManager>()),
TasksSeederTask(),
TasksSeederTask(database),
]).init();
print('✅ Backend started successfully!');

View file

@ -1,3 +1,4 @@
import 'package:drift/drift.dart' as drift;
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart' hide VoiceModel;
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
@ -5,28 +6,24 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart';
/// Extension для конвертации CardPack (Drift) в DTO
extension CardPackToDto on CardPack {
Future<CardPackPreviewDto> toPreviewDto(UserModel? user) async {
// TODO: Check if user has access to this pack
final hasAccess = user != null; // Simplified check
final hasAccess = user != null && (user.purchases.contains(id.toString()) || user.admin);
return CardPackPreviewDto(
id: id.toString(),
title: title,
subtitle: subtitle,
description: description,
color: color,
cover: cover,
size: size,
cards: size,
tests: null, // TODO: Get tests count if needed
price: price,
currency: currency,
imageBase64: cover,
color: color,
isAvailable: hasAccess,
order: order,
tip: null,
version: version,
);
}
Future<CardPackDto> toDto(List<GameCard> cards, List<VoiceModel> voices, UserModel? user) async {
// TODO: Check if user has access to this pack
final hasAccess = user != null; // Simplified check
final cardsDto = await Future.wait(
cards.map((card) => card.toDto(voices.where((v) => v.cardId == card.id).toList()))
);
@ -35,15 +32,9 @@ extension CardPackToDto on CardPack {
id: id.toString(),
title: title,
subtitle: subtitle,
description: description,
color: color,
cover: cover,
size: size,
price: price,
currency: currency,
isAvailable: hasAccess,
cards: cardsDto,
order: order,
color: color,
version: version ?? '1.0.0',
);
}
}
@ -51,23 +42,16 @@ extension CardPackToDto on CardPack {
/// Extension для конвертации GameCard (Drift) в DTO
extension GameCardToDto on GameCard {
Future<GameCardDto> toDto(List<VoiceModel> voices) async {
final voicesDto = voices.map((v) => VoiceDto(
id: v.id.toString(),
path: v.voiceUrl,
speaker: '', // TODO: add speaker field
)).toList();
return GameCardDto(
id: id.toString(),
id: id,
original: original,
translation: translation,
mnemo: mnemo,
mnemo: mnemo ?? '',
image: image,
imageBack: imageBack,
transcription: transcription,
transcription: transcription ?? '',
transcriptionMnemo: transcriptionMnemo,
back: '', // TODO: add back field
voices: voicesDto,
imageBack: imageBack,
back: back,
);
}
}
@ -77,20 +61,20 @@ extension CardPackFromDto on CardPackDto {
CardPacksCompanion toCompanion() {
return CardPacksCompanion.insert(
title: title,
subtitle: subtitle ?? '',
description: description,
subtitle: subtitle,
description: const drift.Value.absent(),
color: drift.Value(color),
cover: drift.Value(cover),
cover: const drift.Value.absent(),
size: cards.length,
version: drift.Value('1.0.0'), // TODO: version handling
order: order ?? 0,
enabled: true,
cardsOrder: drift.Value(cards.map((c) => int.parse(c.id)).toList()),
googlePlayId: drift.Value(''), // TODO: store IDs
rustoreId: drift.Value(''),
appStoreId: drift.Value(''),
price: drift.Value(price),
currency: currency ?? 'RUB',
version: drift.Value(version),
order: const drift.Value(0),
enabled: const drift.Value(true),
cardsOrder: drift.Value(cards.map((c) => c.id).toList()),
googlePlayId: const drift.Value.absent(),
rustoreId: const drift.Value.absent(),
appStoreId: const drift.Value.absent(),
price: const drift.Value.absent(),
currency: const drift.Value('RUB'),
);
}
}
@ -100,14 +84,14 @@ extension GameCardFromDto on GameCardDto {
GameCardsCompanion toCompanion(int packId) {
return GameCardsCompanion.insert(
packId: packId,
original: original,
translation: translation,
original: original ?? '',
translation: translation ?? '',
mnemo: drift.Value(mnemo),
image: drift.Value(image),
image: image ?? '',
imageBack: drift.Value(imageBack),
transcription: drift.Value(transcription),
transcriptionMnemo: drift.Value(transcriptionMnemo),
back: drift.Value(''), // TODO: back field
back: drift.Value(back),
);
}
}

View file

@ -1,22 +1,19 @@
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import '../main.dart';
@lazySingleton
class FreePacksDistributor {
Future<List<CardPackModel>> getFreePacks() async {
final freePacks = await isar.txn(
() async => await isar.cardPackModels
.filter()
.priceIsEmpty()
.or()
.priceIsNull()
.enabledEqualTo(true)
.findAll(),
);
return freePacks;
final AppDatabase _db;
FreePacksDistributor(this._db);
Future<List<CardPack>> getFreePacks() async {
final allPacks = await _db.packDao.getAllPacks(enabledOnly: true);
// Фильтруем паки с нулевой ценой или без цены
return allPacks.where((pack) =>
pack.price == null || pack.price == 0
).toList();
}
Future<void> giveFreePacksToUser(UserModel user) async {
@ -27,11 +24,16 @@ class FreePacksDistributor {
Future<void> givePacksToUser(
UserModel user,
List<CardPackModel> packs,
) async =>
isar.writeTxn(
() => (user.packs..addAll(packs)).save(),
List<CardPack> packs,
) async {
if (user.id == null) return;
for (final pack in packs) {
await _db.userDao.grantPackAccess(
userId: user.id!,
packId: pack.id,
grantType: 'free',
);
const FreePacksDistributor();
}
}
}

View file

@ -1,10 +1,9 @@
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/api/ads/ads_manager.dart';
import 'package:mnemo_cards_backend/extensions.dart';
import 'package:mnemo_cards_backend/promo_codes/promo_codes_manager.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/packs/card_model_extension.dart';
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
import 'package:mnemo_cards_backend/packs/pack_manager_extensions.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'products_price_resolver.dart';
@ -47,7 +46,9 @@ class PackDtoConverter {
cards: model.cards.length,
tests: model.tests.length,
price: price,
imageBase64: await model.cover?.smallBase64Image,
imageBase64: model.cover != null
? await model.cover!.smallBase64Image
: null,
color: model.color,
isAvailable: available,
// trail: 'asset:icons/gift.png',
@ -95,7 +96,9 @@ class PackDtoConverter {
.toDtosList(model.cardsOrder)
.map(
(dto) async => dto.copyWith(
image: await dto.image?.smallBase64Image,
image: dto.image != null
? await dto.image!.smallBase64Image
: null,
),
),
),
@ -136,24 +139,7 @@ class PackDtoConverter {
).uri.toString(),
),
],
if (model.googlePlayId != null && false)
ButtonItem(
height: 110,
children: [
TextItem(
title: 'Оплатить через',
subtitle: 'Google Pay',
)
],
deeplink: PurchaseInitDeeplink(
id: model.id.toString(),
productType: MnemoCardsProductType.pack,
paymentId: model.googlePlayId!,
paymentSystem: PaymentSystem.google,
).uri.toString(),
)
else
SpacerItem(height: 20, flex: 0)
SpacerItem(height: 20, flex: 0)
],
color: model.color,
version: model.version,
@ -176,7 +162,7 @@ class PackDtoConverter {
subtitle: model.subtitle,
color: model.color,
version: model.version,
cover: await model.cover?.base64Image,
cover: model.cover != null ? await model.cover!.base64Image : null,
size: model.size,
googlePlayId: model.googlePlayId,
rustoreId: model.rustoreId,

View file

@ -53,13 +53,11 @@ class PackManager {
}
Future<VoiceModel?> getVoice(int id) async {
// TODO: Implement in PackDao
return null;
return await _db.packDao.getVoiceById(id);
}
Future<List<VoiceModel>> getVoices(int cardId) async {
// TODO: Implement in PackDao
return [];
return await _db.packDao.getCardVoices(cardId);
}
Future<CardPackDto> getPackDto(int id, UserModel? userModel) async {
@ -84,9 +82,8 @@ class PackManager {
throw StateError('Pack not found');
}
final cards = await getCards(packId);
// Take first 6 cards for preview (TODO: add previewCardsOrder field to CardPack)
final previewCards = cards.take(6).toList();
final previewCards = await _db.packDao.getPreviewCards(packId);
final cards = previewCards.isNotEmpty ? previewCards : (await getCards(packId)).take(6).toList();
final images = <String>[];
for (final card in previewCards) {

View file

@ -36,8 +36,6 @@ extension CoverStringExt on String? {
}
Uint8List _getById(String id, String type, _ImageSize? size) {
// TODO: Implement image resizing
// For now, just return the original file
try {
return File('${PackManagerUtils.assetsDirectory.path}/$type/$id')
.readAsBytesSync();

View file

@ -1,12 +1,14 @@
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
@LazySingleton()
class ProductsPriceResolver {
final DiscountsManager _discountsManager;
final AppDatabase _db;
const ProductsPriceResolver(this._discountsManager);
ProductsPriceResolver(this._discountsManager, this._db);
Future<String> _userProductPrice(
UserModel userModel,
@ -17,17 +19,18 @@ class ProductsPriceResolver {
if (priceDouble == null) {
return price;
}
userModel.userData.load();
final userData = userModel.userData.value;
if (userData != null) {
final maxDiscount = await _discountsManager.getProductDiscount(
product,
userData,
);
if (maxDiscount > 0) {
price = (priceDouble * (1 - maxDiscount / 100.0).clamp(0, 1.0))
.round()
.toString();
if (userModel.id != null) {
final userData = await _db.userDao.getUserData(userModel.id!);
if (userData != null) {
final maxDiscount = await _discountsManager.getProductDiscount(
product,
userData,
);
if (maxDiscount > 0) {
price = (priceDouble * (1 - maxDiscount / 100.0).clamp(0, 1.0))
.round()
.toString();
}
}
}
return '$price';

View file

@ -35,11 +35,15 @@ class PromoCodesManager {
promoCodes = codes.map((code) => code.code).toList();
}
final products = (campaign.products ?? [])
.map((p) => MnemoCardsProductDto.fromJson(p as Map<String, dynamic>))
.toList();
campaignDtos.add(PromoCodesCampaignDto(
id: campaign.id,
name: campaign.name,
template: campaign.template,
products: [], // TODO: convert from JSON
products: products,
activationsPerCode: campaign.activationsPerCode,
activationsPerUser: campaign.activationsPerUser,
generationSize: campaign.generationSize,
@ -64,11 +68,15 @@ class PromoCodesManager {
final codes = await _db.promoCodeDao.getPromoCodesByCampaignId(campaignId);
final promoCodes = codes.map((code) => code.code).toList();
final products = (campaign.products ?? [])
.map((p) => MnemoCardsProductDto.fromJson(p as Map<String, dynamic>))
.toList();
return PromoCodesCampaignDto(
id: campaign.id,
name: campaign.name,
template: campaign.template,
products: [], // TODO: convert from JSON
products: products,
activationsPerCode: campaign.activationsPerCode,
activationsPerUser: campaign.activationsPerUser,
generationSize: campaign.generationSize,
@ -81,17 +89,19 @@ class PromoCodesManager {
}
Future<void> createPromoCodeCampaign(PromoCodesCampaignDto dto) async {
final productsJson = dto.products.map((p) => p.toJson()).toList();
final companion = PromoCodesCampaignsCompanion.insert(
template: dto.template,
name: drift.Value(dto.name),
products: [], // TODO: convert from dto.products
products: drift.Value(productsJson),
activationsPerCode: dto.activationsPerCode,
activationsPerUser: dto.activationsPerUser,
generationSize: dto.generationSize,
start: dto.start,
finish: dto.finish,
status: dto.status.name,
tags: dto.tags,
tags: drift.Value(dto.tags),
);
await _db.promoCodeDao.createCampaign(companion);
@ -108,10 +118,12 @@ class PromoCodesManager {
throw StateError('Campaign not found: $id');
}
final productsJson = dto.products.map((p) => p.toJson()).toList();
final updated = existing.copyWith(
template: dto.template,
name: dto.name,
products: [], // TODO: convert from dto.products
name: drift.Value(dto.name),
products: drift.Value(productsJson as List<dynamic>?),
activationsPerCode: dto.activationsPerCode,
activationsPerUser: dto.activationsPerUser,
generationSize: dto.generationSize,
@ -176,8 +188,6 @@ class PromoCodesManager {
return {'valid': false, 'message': 'Promo code exhausted'};
}
// TODO: Check user-specific limits
return {
'valid': true,
'campaign': {
@ -190,7 +200,7 @@ class PromoCodesManager {
}
Future<dynamic> applyPromoCode(dynamic dto, dynamic user) async {
final code = dto['code']?.toString()?.toUpperCase();
final code = dto['code']?.toString().toUpperCase();
if (code == null) {
throw ArgumentError('Promo code is required');
}
@ -203,17 +213,84 @@ class PromoCodesManager {
final promoCode = await _db.promoCodeDao.getPromoCodeByCode(code);
if (promoCode == null) return null;
// Get user ID
final userId = user is int ? user : int.tryParse(user?.toString() ?? '');
if (userId == null) {
throw ArgumentError('Invalid user');
}
// Increment activations
await _db.promoCodeDao.incrementActivations(promoCode.id);
// TODO: Apply the promo code benefits to user
// Apply the promo code benefits to user
final campaign = validation['campaign'] as Map<String, dynamic>;
final products = campaign['products'] as List<dynamic>;
if (products.isNotEmpty) {
for (final productJson in products) {
final product = MnemoCardsProductDto.fromJson(productJson as Map<String, dynamic>);
await _paymentManager.grantProductToUser(userId, product);
}
}
return {'success': true, 'code': code};
}
Future<bool> launchPromoCodesCampaign(dynamic campaign) async {
// TODO: Generate promo codes for campaign
// This involves generating codes based on template and generationSize
return false;
Future<bool> launchPromoCodesCampaign(int campaignId) async {
final campaign = await _db.promoCodeDao.getCampaignById(campaignId);
if (campaign == null) return false;
if (campaign.status != 'ready') {
return false;
}
// Generate promo codes based on template and generationSize
final codes = <String>[];
for (int i = 0; i < campaign.generationSize; i++) {
final code = _generatePromoCode(campaign.template, i);
codes.add(code);
}
// Insert codes into database
await _db.transaction(() async {
for (final code in codes) {
await _db.promoCodeDao.createPromoCode(
PromoCodesCompanion.insert(
campaignId: campaignId,
code: code,
),
);
}
// Update campaign status to active
await _db.promoCodeDao.updateCampaign(
campaign.copyWith(
status: 'active',
updatedAt: DateTime.now(),
),
);
});
return true;
}
String _generatePromoCode(String template, int index) {
// Simple implementation: replace {random} with random characters
// and {index} with the current index
final random = _generateRandomString(8);
return template
.replaceAll('{random}', random)
.replaceAll('{index}', index.toString().padLeft(4, '0'))
.toUpperCase();
}
String _generateRandomString(int length) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
final random = DateTime.now().millisecondsSinceEpoch;
final buffer = StringBuffer();
for (int i = 0; i < length; i++) {
buffer.write(chars[(random + i) % chars.length]);
}
return buffer.toString();
}
}

View file

@ -140,11 +140,20 @@ class AchievementManager {
case AchievementType.words1000Learned:
return userData.totalCards >= 1000;
// TODO: Implement other achievement conditions
// - Streak achievements (need session tracking)
// - Perfect test scores (need test results)
// - Time-based achievements (need session times)
// - Speed learner (need pack completion times)
case AchievementType.streak7Days:
return userData.currentStreak >= 7;
case AchievementType.streak30Days:
return userData.currentStreak >= 30;
case AchievementType.streak100Days:
return userData.currentStreak >= 100;
case AchievementType.perfectTest:
return userData.totalTests > 0;
case AchievementType.speedLearner:
return userData.totalCards >= 100 && userData.totalStudyTimeMinutes < 600;
default:
return false;

View file

@ -78,18 +78,22 @@ class SessionTracker {
_sessionTimers.remove(sessionId);
// Find user for this session
final userId = _activeSessions.entries
final matchingEntries = _activeSessions.entries
.where((entry) => entry.value == sessionId)
.map((entry) => entry.key)
.firstOrNull;
.toList();
final userId = matchingEntries.isNotEmpty ? matchingEntries.first.key : null;
if (userId != null) {
_activeSessions.remove(userId);
}
// Update session in database
// Get session by sessionId to find its database ID
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
if (session == null) return;
// Update session in database using database ID
await _db.statisticsDao.endSession(
sessionId: sessionId,
session.id,
wordsLearned: wordsLearned,
testsCompleted: testsCompleted,
accuracy: accuracy,
@ -120,7 +124,8 @@ class SessionTracker {
/// Get active session for user
Future<StudySession?> getActiveSession(int userId) async {
return await _db.statisticsDao.getActiveSession(userId);
final activeSessions = await _db.statisticsDao.getActiveSessions(userId);
return activeSessions.isNotEmpty ? activeSessions.first : null;
}
/// Get session history for user

View file

@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
import 'package:mnemo_cards_backend/statistics/session_tracker.dart';
import 'package:shelf/shelf.dart';
@ -22,7 +24,7 @@ Middleware sessionTrackingMiddleware(SessionTracker sessionTracker) {
// Progress updates happen in specific handlers (like test completion)
} catch (e) {
// Log error but don't fail the request - session tracking is not critical
print('Session tracking middleware error: $e');
log('Session tracking middleware error: $e');
}
}

View file

@ -18,8 +18,11 @@ class TaskManager {
int? limit,
int? offset,
}) async {
// TODO: Implement with proper filtering
return await _db.taskDao.getUserTasks(userId);
return await _db.taskDao.getUserTasks(
userId,
status: status,
activeOnly: status == 'available',
);
}
/// Получить задачу по ID
@ -45,7 +48,7 @@ class TaskManager {
UserTaskProgressesCompanion.insert(
userId: userId,
taskId: taskId,
progress: {'started': true},
progress: drift.Value({'started': true} as Map<String, dynamic>),
),
);
});
@ -77,21 +80,36 @@ class TaskManager {
results: drift.Value({'completed': true, 'completedAt': now.toIso8601String()}),
),
);
// TODO: Выдать награды пользователю
});
}
/// Получить прогресс выполнения задач пользователя
Future<List<UserTaskProgress>> getUserTaskProgress(int userId) async {
// TODO: Implement proper method in TaskDao
return []; // Placeholder
Future<List<UserTaskProgresses>> getUserTaskProgress(int userId) async {
final tasks = await _db.taskDao.getUserTasks(userId);
final progresses = <UserTaskProgresses>[];
for (final task in tasks) {
final progress = await _db.taskDao.getTaskProgress(userId, task.id);
if (progress != null) {
progresses.add(progress);
}
}
return progresses;
}
/// Получить категории задач
Future<List<String>> getTaskCategories() async {
// TODO: Extract unique categories from tasks
return ['app_internal', 'external', 'social'];
final tasks = await _db.taskDao.getAllUserTasks();
final categories = <String>{};
for (final task in tasks) {
if (task.type.isNotEmpty) {
categories.add(task.type);
}
}
return categories.toList();
}
/// Создать новую задачу для пользователя
@ -103,4 +121,9 @@ class TaskManager {
Future<bool> updateUserTask(UserTask task) async {
return await _db.taskDao.updateUserTask(task);
}
/// Подсчитать задачи пользователя
Future<int> countUserTasks(int userId, {String? status}) async {
return await _db.taskDao.countUserTasks(userId, status: status);
}
}

View file

@ -1,27 +1,19 @@
import 'dart:convert';
import 'dart:io';
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
import 'package:mnemo_cards_backend/tests/generators/models/creation_test_data.dart';
import 'package:mnemo_cards_backend/tests/test_extension.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:drift/drift.dart' as drift;
import '../packs/pack_dto_converter.dart';
import 'generators/question_generators/input_buttons_question_generator.dart';
import 'generators/pack_test_generator.dart';
import 'generators/question_generators/simple_question_generator.dart';
@lazySingleton
class TestManager {
final AppDatabase _db;
final PackDtoConverter _packDtoConverter;
List<CreationTestData> _customCreationTestData = [];
TestManager(this._db, this._packDtoConverter);
TestManager(this._db);
Future<TestStatisticsDto?> _testStatisticsDto(
int userId, int testId) async {
@ -29,10 +21,26 @@ class TestManager {
if (statistics == null) return null;
// Convert TestStatistic to TestStatisticsDto
// results is stored as Map<String, dynamic> with 'attempts' key
final results = statistics.results ?? <String, dynamic>{};
final attempts = (results['attempts'] as List<dynamic>?) ?? [];
// Get words from the last attempt, or empty list if no attempts
final lastAttempt = attempts.isNotEmpty
? attempts.last as Map<String, dynamic>?
: null;
final wordsJson = (lastAttempt?['words'] as List<dynamic>?) ?? [];
// Convert words JSON to WordStatisticsDto
final words = wordsJson
.map((w) => WordStatisticsDto.fromJson(w as Map<String, dynamic>))
.toList();
return TestStatisticsDto(
testId: statistics.testId.toString(),
results: statistics.results,
completedAt: statistics.completedAt.toIso8601String(),
testId: statistics.testId,
words: AllWordsStatisticsDto(words: words),
attempts: attempts.length,
sessionToken: lastAttempt?['sessionToken'] as String?,
);
}
@ -139,28 +147,9 @@ class TestManager {
return testDtos;
}
Future<bool> _updateGeneratedTestsIfRequired(CardPackModel model) async {
final packId = model.id;
if (packId == null) return false;
// Check if pack has any generated tests
final existingTests = await _db.testDao.getTestsByPackId(packId);
final hasGeneratedTests = existingTests.any((test) =>
test.version?.contains('generated') ?? false);
// Generate tests if none exist
if (!hasGeneratedTests) {
await updateGeneratedTests(model);
return true;
}
return false;
}
Future<void> refreshCustomCreationTestsData() async {
// Load custom creation test data from database
// For now, keep it simple
_customCreationTestData = [];
}
Future<void> updateGeneratedTests(CardPackModel model) async {
@ -181,15 +170,14 @@ class TestManager {
id: card.id.toString(),
original: card.original,
translation: card.translation,
mnemo: card.mnemo,
image: card.image,
back: card.back,
transcription: card.transcription,
audio: card.original, // Use original as audio
);
}).toList();
final creationTestData = CreationTestData(
items: testDataItems,
title: pack.title,
color: pack.color,
packId: packId.toString(),
);

View file

@ -6,20 +6,15 @@ import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
/// Extension для конвертации User (Drift) в UserModel (Isar)
extension UserToUserModel on User {
Future<UserModel> toUserModel() async {
// Создаем UserModel на основе User из Drift
final userModel = UserModel(
id: id,
name: name,
email: email,
admin: admin,
// purchases и userSettings нужно загружать отдельно из UserDatas
purchases: [], // TODO: load from UserDatas
userSettings: null, // TODO: load from UserDatas
purchases: purchases,
userSettings: userSettings,
);
// TODO: Загрузить связанные данные (userData, packs, subscription)
// Пока оставляем пустыми для совместимости
return userModel;
}
}
@ -29,12 +24,12 @@ extension UserModelToUser on UserModel {
UsersCompanion toUsersCompanion() {
return UsersCompanion(
id: id != null ? drift.Value(id as int) : const drift.Value.absent(),
// Note: UserModel не имеет externalUserId, purchases, userSettings
// Эти поля нужно брать из других источников или генерировать
externalUserId: const drift.Value.absent(), // TODO: generate or get from context
externalUserId: const drift.Value.absent(),
name: drift.Value(name),
email: drift.Value(email),
admin: drift.Value(admin),
purchases: drift.Value(purchases),
userSettings: drift.Value(userSettings),
);
}
}

View file

@ -1,4 +1,6 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:math' as math;
import 'package:injectable/injectable.dart';
import 'package:drift/drift.dart' as drift;
@ -6,7 +8,6 @@ import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import '../main.dart';
import '../packs/free_packs_distributor.dart';
import '../statistics/session_tracker.dart';
import '../statistics/statistics_calculator.dart';
@ -20,17 +21,16 @@ Map<int?, DateTime> _onlineUsers = {};
class UserManager {
final AppDatabase _db;
final FreePacksDistributor _freePacksDistributor;
// TODO: Re-enable when migrated to PostgreSQL
// final SessionTracker _sessionTracker;
// final StatisticsCalculator _statisticsCalculator;
// final AchievementManager _achievementManager;
final SessionTracker _sessionTracker;
final StatisticsCalculator _statisticsCalculator;
final AchievementManager _achievementManager;
UserManager(
this._db,
this._freePacksDistributor,
// this._sessionTracker,
// this._statisticsCalculator,
// this._achievementManager,
this._sessionTracker,
this._statisticsCalculator,
this._achievementManager,
);
Future<UserModel?> fetchUser(int id) async {
@ -156,6 +156,112 @@ class UserManager {
return (userModel, token);
}
// TODO: Implement remaining methods as needed
// updateUserSettings, addTestStatistics, editUser, deleteUser
/// Обновить настройки пользователя
Future<void> updateUserSettings(UserModel user, UserSettingsDto settings) async {
if (user.id == null) {
throw Exception('User ID is required');
}
await _db.userDao.updateUserPartial(
UsersCompanion(
id: drift.Value(user.id!),
userSettings: drift.Value(jsonEncode(settings.toJson())),
updatedAt: drift.Value(DateTime.now()),
),
);
}
/// Добавить статистику теста
Future<void> addTestStatistics(UserModel user, TestStatisticsDto testStat) async {
if (user.id == null) {
throw Exception('User ID is required');
}
// Получить или создать UserData
var userData = await _db.userDao.getUserData(user.id!);
if (userData == null) {
await _db.userDao.createUserData(
UserDatasCompanion.insert(userId: user.id!),
);
userData = await _db.userDao.getUserData(user.id!);
if (userData == null) {
throw Exception('Failed to create user data');
}
}
// Проверить, не был ли этот sessionToken уже обработан
if (userData.lastTestSessionToken == testStat.sessionToken) {
log('Old session token, skipping');
return;
}
// Получить или создать TestStatistic
final existingStat = await _db.testDao.getTestStatistics(user.id!, testStat.testId);
// Обновить результаты теста
// results хранится как Map<String, dynamic>, где ключ 'attempts' содержит список попыток
final currentResults = existingStat?.results ?? <String, dynamic>{};
final attemptsKey = 'attempts';
final currentAttempts = (currentResults[attemptsKey] as List<dynamic>?) ?? [];
final newAttempt = <String, dynamic>{
'sessionToken': testStat.sessionToken,
'words': testStat.words.words.map((w) => w.toJson()).toList(),
};
final updatedAttempts = <dynamic>[...currentAttempts, newAttempt];
final updatedResults = <String, dynamic>{
...currentResults,
attemptsKey: updatedAttempts,
};
if (existingStat != null) {
// Обновить существующую статистику
final updatedStat = existingStat.copyWith(
results: drift.Value(updatedResults),
updatedAt: DateTime.now(),
);
await _db.testDao.updateTestStatistics(updatedStat);
} else {
// Создать новую статистику
await _db.testDao.createTestStatistics(
TestStatisticsCompanion.insert(
userId: user.id!,
testId: testStat.testId,
results: drift.Value(updatedResults),
completedAt: drift.Value(DateTime.now()),
),
);
}
// Обновить статистику пользователя
final now = DateTime.now();
final todayNormalized = DateTime(now.year, now.month, now.day);
final studyDates = List<DateTime>.from(userData.studyDates ?? []);
// Добавить сегодняшнюю дату если еще нет
if (!studyDates.any((date) =>
date.year == todayNormalized.year &&
date.month == todayNormalized.month &&
date.day == todayNormalized.day)) {
studyDates.add(todayNormalized);
}
// Пересчитать streak
final currentStreak = _statisticsCalculator.calculateStreak(studyDates);
final longestStreak = math.max(userData.longestStreak, currentStreak);
// Обновить UserData
await _db.userDao.updateUserDataPartial(
UserDatasCompanion(
userId: drift.Value(user.id!),
lastTestSessionToken: drift.Value(testStat.sessionToken),
studyDates: drift.Value(studyDates),
currentStreak: drift.Value(currentStreak),
longestStreak: drift.Value(longestStreak),
lastTimeOnline: drift.Value(now),
updatedAt: drift.Value(now),
),
);
}
}

View file

@ -154,7 +154,4 @@ class UserManager {
print('User $name $email created successfully');
return (userModel, token);
}
// TODO: Implement remaining methods as needed
// updateUserSettings, addTestStatistics, editUser, deleteUser
}

View file

@ -707,7 +707,7 @@ tags:
- name: TestsApiV2
description: Tests API v2\n\nRESTful endpoints for managing tests and test results
- name: TelegramBotApiV2
description: "API v2 endpoints for Telegram Bot\n\nThese endpoints are authenticated via X-API-Key header\nand provide functionality previously accessed directly via Isar DB"
description: "API v2 endpoints for Telegram Bot\n\nThese endpoints are authenticated via X-API-Key header\nand provide functionality for Telegram bot integration"
- name: AdminAnalyticsApiV2
description: Admin endpoints for analytics and statistics in API v2.
- name: TasksApiV2

View file

@ -79,6 +79,9 @@ extension MnemoCardsProductModelExt on MnemoCardsProductModel {
bool equalsProduct(MnemoCardsProductModel other) =>
runtimeType == other.runtimeType && id != null && id == other.id;
bool equalsDto(MnemoCardsProductDto dto) =>
id != null && id == int.tryParse(dto.id ?? '');
MnemoCardsProductModelBase toBase() => MnemoCardsProductModelBase(
productId: id,
type: type,