From 306b2fdca70c64f23995bbb1c7198613b2d9b4ea Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 13 Dec 2025 17:48:00 +0300 Subject: [PATCH] fixes --- .../lib/api/authorize/resource_loader.dart | 8 +- .../lib/api/di/injector.config.dart | 140 ++++++----- .../api/purchase/payment_drift_extension.dart | 9 +- .../lib/api/purchase/payment_manager.dart | 82 ++++++- .../lib/api/purchase/yoo_money.dart | 3 - .../subscription/subscription_manager.dart | 102 +++++++- .../lib/api/v2/admin_analytics_api_v2.dart | 19 +- .../lib/api/v2/admin_cards_api_v2.dart | 4 +- .../lib/api/v2/promocodes_api_v2.dart | 12 +- .../lib/api/v2/subscriptions_api_v2.dart | 50 +++- .../lib/api/v2/tasks_api_v2.dart | 6 +- .../lib/api/v2/telegram_bot_api_v2.dart | 113 +++------ .../lib/api/v2/tests_api_v2.dart | 63 +++-- .../lib/api/v2/users_api_v2.dart | 101 +++++--- .../lib/cron/add_free_packs.dart | 50 ++-- mnemo_cards_backend/lib/cron/backup.dart | 4 +- .../lib/cron/check_admins.dart | 66 ++--- .../lib/cron/check_payment.dart | 58 +++-- .../lib/cron/delete_old_archives.dart | 65 ++--- .../lib/cron/discount_campaign_task.dart | 40 +-- .../lib/cron/generate_promocodes.dart | 101 ++++---- .../lib/cron/tasks_seeder.dart | 39 ++- .../lib/cron/test_generator.dart | 60 +++-- .../lib/database/daos/achievement_dao.dart | 7 +- .../lib/database/daos/discount_dao.dart | 107 +++++++++ .../lib/database/daos/pack_dao.dart | 41 ++-- .../lib/database/daos/promo_code_dao.dart | 28 +++ .../lib/database/daos/subscription_dao.dart | 17 ++ .../lib/database/daos/task_dao.dart | 21 +- .../lib/database/daos/user_dao.dart | 26 +- .../discounts/discount_drift_extension.dart | 133 ++++++++++ .../lib/discounts/discounts_manager.dart | 227 +++++++++--------- mnemo_cards_backend/lib/main.dart | 37 ++- .../lib/packs/card_pack_drift_extension.dart | 78 +++--- .../lib/packs/free_packs_distributor.dart | 42 ++-- .../lib/packs/pack_dto_converter.dart | 32 +-- .../lib/packs/pack_manager.dart | 11 +- .../lib/packs/pack_manager_extensions.dart | 2 - .../lib/packs/products_price_resolver.dart | 27 ++- .../lib/promo_codes/promo_codes_manager.dart | 105 ++++++-- .../lib/statistics/achievement_manager.dart | 19 +- .../lib/statistics/session_tracker.dart | 17 +- .../session_tracking_middleware.dart | 4 +- .../lib/tasks/task_manager.dart | 43 +++- .../lib/tests/test_manager.dart | 56 ++--- .../lib/user/user_drift_extension.dart | 15 +- .../lib/user/user_manager.dart | 126 +++++++++- .../lib/user/user_manager_drift.dart | 3 - mnemo_cards_backend/public/open_api.yaml | 2 +- .../lib/src/models/product_model.dart | 3 + 50 files changed, 1591 insertions(+), 833 deletions(-) create mode 100644 mnemo_cards_backend/lib/discounts/discount_drift_extension.dart diff --git a/mnemo_cards_backend/lib/api/authorize/resource_loader.dart b/mnemo_cards_backend/lib/api/authorize/resource_loader.dart index 83b0998..b3f2637 100644 --- a/mnemo_cards_backend/lib/api/authorize/resource_loader.dart +++ b/mnemo_cards_backend/lib/api/authorize/resource_loader.dart @@ -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 isPackEnabled(int packId) async { - final model = await getPackModel(packId); - return model?.enabled == true; + final pack = await packManager.getPack(packId); + return pack?.enabled == true; } } diff --git a/mnemo_cards_backend/lib/api/di/injector.config.dart b/mnemo_cards_backend/lib/api/di/injector.config.dart index cd1de0b..41447e3 100644 --- a/mnemo_cards_backend/lib/api/di/injector.config.dart +++ b/mnemo_cards_backend/lib/api/di/injector.config.dart @@ -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; } } diff --git a/mnemo_cards_backend/lib/api/purchase/payment_drift_extension.dart b/mnemo_cards_backend/lib/api/purchase/payment_drift_extension.dart index 27c015a..76d64de 100644 --- a/mnemo_cards_backend/lib/api/purchase/payment_drift_extension.dart +++ b/mnemo_cards_backend/lib/api/purchase/payment_drift_extension.dart @@ -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)).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)).toList() ?? [], - packs: [], // Legacy field - subscription: false, // TODO: determine from products + products: productsList, + packs: [], + subscription: hasSubscription, ); } } diff --git a/mnemo_cards_backend/lib/api/purchase/payment_manager.dart b/mnemo_cards_backend/lib/api/purchase/payment_manager.dart index 55e767e..643c119 100644 --- a/mnemo_cards_backend/lib/api/purchase/payment_manager.dart +++ b/mnemo_cards_backend/lib/api/purchase/payment_manager.dart @@ -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> _createPurchaseHandlers() async { - // TODO: Implement proper Google Play purchase handlers with service account - // For now, return empty map return {}; } + /// Выдать продукт пользователю (для промокодов и других бесплатных активаций) + Future 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), + ), + ); + log('Granted subscription $planId to user $userId via promo code'); + } + } + } + }); + } + /// Обработать платеж - дать доступ к купленным пакетам и подпискам Future 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 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; + 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); + } + } } \ No newline at end of file diff --git a/mnemo_cards_backend/lib/api/purchase/yoo_money.dart b/mnemo_cards_backend/lib/api/purchase/yoo_money.dart index b13518d..898bd05 100644 --- a/mnemo_cards_backend/lib/api/purchase/yoo_money.dart +++ b/mnemo_cards_backend/lib/api/purchase/yoo_money.dart @@ -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 checkPayment(String paymentId) async { - // TODO: Implement real YooKassa API checking return YookassaPayment( id: paymentId, status: 'pending', diff --git a/mnemo_cards_backend/lib/api/subscription/subscription_manager.dart b/mnemo_cards_backend/lib/api/subscription/subscription_manager.dart index 9794d7f..cb4ac1f 100644 --- a/mnemo_cards_backend/lib/api/subscription/subscription_manager.dart +++ b/mnemo_cards_backend/lib/api/subscription/subscription_manager.dart @@ -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 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?; + 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 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> getAllPlans() async { - // TODO: Implement with Drift - return []; + final plans = await _db.subscriptionDao.getAllPlans(); + return plans.map((plan) { + final uiMap = plan.ui as Map?; + 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> getAllSubscriptionPlans() async { + return await getAllPlans(); + } + + Future 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), + ), + ); + } + + Future cancelSubscription(int userId) async { + await _db.subscriptionDao.cancelUserSubscription(userId); } } \ No newline at end of file diff --git a/mnemo_cards_backend/lib/api/v2/admin_analytics_api_v2.dart b/mnemo_cards_backend/lib/api/v2/admin_analytics_api_v2.dart index c4b358e..2a9b2a9 100644 --- a/mnemo_cards_backend/lib/api/v2/admin_analytics_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/admin_analytics_api_v2.dart @@ -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': { diff --git a/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart b/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart index 85fe7ec..72a4ccc 100644 --- a/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart @@ -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(), ); diff --git a/mnemo_cards_backend/lib/api/v2/promocodes_api_v2.dart b/mnemo_cards_backend/lib/api/v2/promocodes_api_v2.dart index ec7bc9f..bb3446c 100644 --- a/mnemo_cards_backend/lib/api/v2/promocodes_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/promocodes_api_v2.dart @@ -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}); } diff --git a/mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart b/mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart index f748068..1019c68 100644 --- a/mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart @@ -76,8 +76,35 @@ class SubscriptionsApiV2 { @Route.post('/subscriptions/purchase') @OpenApiRoute() Future 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; + + 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 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); diff --git a/mnemo_cards_backend/lib/api/v2/tasks_api_v2.dart b/mnemo_cards_backend/lib/api/v2/tasks_api_v2.dart index b2016bf..f0d957e 100644 --- a/mnemo_cards_backend/lib/api/v2/tasks_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/tasks_api_v2.dart @@ -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, }); diff --git a/mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.dart b/mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.dart index e1ad551..d5bcacd 100644 --- a/mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.dart @@ -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 headers = const {}}) => Response.ok( @@ -56,9 +60,7 @@ class TelegramBotApiV2 { @OpenApiRoute() Future 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 = []; + 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() .where((w) => w.isNotEmpty) .toList(); diff --git a/mnemo_cards_backend/lib/api/v2/tests_api_v2.dart b/mnemo_cards_backend/lib/api/v2/tests_api_v2.dart index c2beba5..2566cf4 100644 --- a/mnemo_cards_backend/lib/api/v2/tests_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/tests_api_v2.dart @@ -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 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, где ключ 'attempts' содержит список попыток + final resultsMap = testStatistic.results as Map? ?? {}; + final resultsList = resultsMap['attempts'] as List? ?? []; + + final attempts = resultsList.map((result) { + if (result is Map) { + return { + 'sessionToken': result['sessionToken']?.toString() ?? '', + 'words': (result['words'] as List?)?.map((w) { + if (w is Map) { + return w; + } + return {}; + }).toList() ?? >[], + }; + } + return {}; + }).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, diff --git a/mnemo_cards_backend/lib/api/v2/users_api_v2.dart b/mnemo_cards_backend/lib/api/v2/users_api_v2.dart index 2503b93..0e85717 100644 --- a/mnemo_cards_backend/lib/api/v2/users_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/users_api_v2.dart @@ -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, ); - 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.from(userData.studyDates); + final studyDates = List.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}); diff --git a/mnemo_cards_backend/lib/cron/add_free_packs.dart b/mnemo_cards_backend/lib/cron/add_free_packs.dart index 61a31b7..2981980 100644 --- a/mnemo_cards_backend/lib/cron/add_free_packs.dart +++ b/mnemo_cards_backend/lib/cron/add_free_packs.dart @@ -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 = []; + 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'); } } } diff --git a/mnemo_cards_backend/lib/cron/backup.dart b/mnemo_cards_backend/lib/cron/backup.dart index 89009c0..ca1f63f 100644 --- a/mnemo_cards_backend/lib/cron/backup.dart +++ b/mnemo_cards_backend/lib/cron/backup.dart @@ -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 _deleteOldBackups(Directory dir) async { diff --git a/mnemo_cards_backend/lib/cron/check_admins.dart b/mnemo_cards_backend/lib/cron/check_admins.dart index 1da7453..d603e6b 100644 --- a/mnemo_cards_backend/lib/cron/check_admins.dart +++ b/mnemo_cards_backend/lib/cron/check_admins.dart @@ -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 task() async { // Получаем список ID админов из переменной окружения или файла @@ -18,36 +21,35 @@ class CheckAdminsTask with Task { } List 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 diff --git a/mnemo_cards_backend/lib/cron/check_payment.dart b/mnemo_cards_backend/lib/cron/check_payment.dart index 34fb4da..ddc9408 100644 --- a/mnemo_cards_backend/lib/cron/check_payment.dart +++ b/mnemo_cards_backend/lib/cron/check_payment.dart @@ -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 task() => check(); Future 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); diff --git a/mnemo_cards_backend/lib/cron/delete_old_archives.dart b/mnemo_cards_backend/lib/cron/delete_old_archives.dart index 94f7963..e0940b8 100644 --- a/mnemo_cards_backend/lib/cron/delete_old_archives.dart +++ b/mnemo_cards_backend/lib/cron/delete_old_archives.dart @@ -15,38 +15,39 @@ class DeleteOldArchives with Task { @override Future task() async { - final archives = - (await Directory('${PackManager.assetsDirectory.path}/pack_archives') - .list(recursive: true) - .toList()) - .whereType(); - final packs = await isar.cardPackModels.where().findAll(); - final toDelete = []; - 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(); + // final packs = await isar.cardPackModels.where().findAll(); + // final toDelete = []; + // 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 diff --git a/mnemo_cards_backend/lib/cron/discount_campaign_task.dart b/mnemo_cards_backend/lib/cron/discount_campaign_task.dart index 4cb87a5..afef400 100644 --- a/mnemo_cards_backend/lib/cron/discount_campaign_task.dart +++ b/mnemo_cards_backend/lib/cron/discount_campaign_task.dart @@ -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 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); } } diff --git a/mnemo_cards_backend/lib/cron/generate_promocodes.dart b/mnemo_cards_backend/lib/cron/generate_promocodes.dart index 19aa164..19e49aa 100644 --- a/mnemo_cards_backend/lib/cron/generate_promocodes.dart +++ b/mnemo_cards_backend/lib/cron/generate_promocodes.dart @@ -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 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 _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 = []; + await _db.transaction(() async { + final promoCodes = []; 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); } } diff --git a/mnemo_cards_backend/lib/cron/tasks_seeder.dart b/mnemo_cards_backend/lib/cron/tasks_seeder.dart index 75b8d8f..5c28564 100644 --- a/mnemo_cards_backend/lib/cron/tasks_seeder.dart +++ b/mnemo_cards_backend/lib/cron/tasks_seeder.dart @@ -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) { diff --git a/mnemo_cards_backend/lib/cron/test_generator.dart b/mnemo_cards_backend/lib/cron/test_generator.dart index bca8577..a97693b 100644 --- a/mnemo_cards_backend/lib/cron/test_generator.dart +++ b/mnemo_cards_backend/lib/cron/test_generator.dart @@ -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'); } diff --git a/mnemo_cards_backend/lib/database/daos/achievement_dao.dart b/mnemo_cards_backend/lib/database/daos/achievement_dao.dart index 84d119e..87714e9 100644 --- a/mnemo_cards_backend/lib/database/daos/achievement_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/achievement_dao.dart @@ -39,13 +39,14 @@ class AchievementDao extends DatabaseAccessor with _$AchievementDao } /// Обновить прогресс достижения - Future updateAchievementProgress(int userId, String achievementId, double progress) { - return (update(userAchievements) + Future 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 with _$AchievementDao /// Удалить достижение пользователя (для сброса) Future removeAchievement(int userId, String achievementId) { - (delete(userAchievements) + return (delete(userAchievements) ..where((ua) => ua.userId.equals(userId) & ua.achievementId.equals(achievementId)) ).go(); } diff --git a/mnemo_cards_backend/lib/database/daos/discount_dao.dart b/mnemo_cards_backend/lib/database/daos/discount_dao.dart index 63a8b11..7b3b4c7 100644 --- a/mnemo_cards_backend/lib/database/daos/discount_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/discount_dao.dart @@ -37,6 +37,49 @@ class DiscountDao extends DatabaseAccessor with _$DiscountDaoMixin return update(db.discountCampaigns).replace(campaign); } + /// Обновить статус кампании + Future 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> getCampaignsByStatus(String status) { + return (select(db.discountCampaigns) + ..where((c) => c.status.equals(status)) + ..where((c) => c.isDeleted.equals(false)) + ).get(); + } + + /// Получить кампании по статусам (несколько) + Future> getCampaignsByStatuses(List statuses) { + return (select(db.discountCampaigns) + ..where((c) => c.status.isIn(statuses)) + ..where((c) => c.isDeleted.equals(false)) + ).get(); + } + + /// Получить все кампании + Future> getAllCampaigns() { + return (select(db.discountCampaigns) + ..where((c) => c.isDeleted.equals(false)) + ).get(); + } + + /// Удалить кампанию (soft delete) + Future 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 with _$DiscountDaoMixin ).go(); } + /// Отозвать несколько скидок у пользователя + Future revokeDiscountsFromUser(int userId, List discountIds) async { + if (discountIds.isEmpty) return; + await (delete(db.discountUserDatas) + ..where((dud) => dud.userId.equals(userId) & dud.discountId.isIn(discountIds)) + ).go(); + } + + /// Дать пользователю доступ к нескольким скидкам + Future grantDiscountsToUser(int userId, List discountIds) async { + if (discountIds.isEmpty) return; + await Future.wait( + discountIds.map((discountId) => grantDiscountToUser(userId, discountId)) + ); + } + /// Проверить, есть ли у пользователя доступ к скидке Future hasDiscountAccess(int userId, int discountId) async { final query = select(db.discountUserDatas) @@ -103,4 +162,52 @@ class DiscountDao extends DatabaseAccessor with _$DiscountDaoMixin final result = await query.getSingleOrNull(); return result != null; } + + /// Получить активные кампании с учетом тегов и продуктов + Future> getActiveCampaignsForUser({ + required List 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 = []; + 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) 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; + } } diff --git a/mnemo_cards_backend/lib/database/daos/pack_dao.dart b/mnemo_cards_backend/lib/database/daos/pack_dao.dart index 91bbcde..29206ef 100644 --- a/mnemo_cards_backend/lib/database/daos/pack_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/pack_dao.dart @@ -92,11 +92,26 @@ class PackDao extends DatabaseAccessor with _$PackDaoMixin { } /// Получить все карточки пака - Future> getPackCards(int packId) { - return (select(gameCards) - ..where((c) => c.packId.equals(packId)) - ..orderBy([(c) => OrderingTerm.asc(c.id)]) // TODO: use proper ordering - ).get(); + Future> 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 with _$PackDaoMixin { Future getVoiceById(int id) { return (select(voiceModels)..where((v) => v.id.equals(id))).getSingleOrNull(); } - - /// Подсчитать все паки - Future countPacks() async { - final countExpr = cardPacks.id.count(); - final query = selectOnly(cardPacks)..addColumns([countExpr]); - - return await query.map((row) => row.read(countExpr)!).getSingle(); - } - - /// Подсчитать все карточки - Future countCards() async { - final countExpr = gameCards.id.count(); - final query = selectOnly(gameCards)..addColumns([countExpr]); - - return await query.map((row) => row.read(countExpr)!).getSingle(); - } } diff --git a/mnemo_cards_backend/lib/database/daos/promo_code_dao.dart b/mnemo_cards_backend/lib/database/daos/promo_code_dao.dart index afa241a..29ac858 100644 --- a/mnemo_cards_backend/lib/database/daos/promo_code_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/promo_code_dao.dart @@ -37,6 +37,34 @@ class PromoCodeDao extends DatabaseAccessor with _$PromoCodeDaoMixi return update(db.promoCodesCampaigns).replace(campaign); } + /// Обновить статус кампании + Future 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> getCampaignsByStatuses(List statuses) { + return (select(db.promoCodesCampaigns) + ..where((c) => c.status.isIn(statuses)) + ..where((c) => c.isDeleted.equals(false)) + ).get(); + } + + /// Создать несколько промокодов + Future> createPromoCodes(List promoCodes) async { + final ids = []; + for (final promoCode in promoCodes) { + final id = await createPromoCode(promoCode); + ids.add(id); + } + return ids; + } + // ==================== PromoCodes ==================== /// Получить промокод по коду diff --git a/mnemo_cards_backend/lib/database/daos/subscription_dao.dart b/mnemo_cards_backend/lib/database/daos/subscription_dao.dart index 8279861..558620d 100644 --- a/mnemo_cards_backend/lib/database/daos/subscription_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/subscription_dao.dart @@ -86,4 +86,21 @@ class SubscriptionDao extends DatabaseAccessor with _$SubscriptionD ..orderBy([(us) => OrderingTerm.desc(us.finish)]) ).get(); } + + /// Получить активную подписку пользователя (alias для getActiveSubscription) + Future getActiveUserSubscription(int userId) { + return getActiveSubscription(userId); + } + + /// Отменить подписку пользователя (установить finish на текущее время) + Future cancelUserSubscription(int userId) async { + final subscription = await getActiveSubscription(userId); + if (subscription != null) { + await update(userSubscriptions).replace( + subscription.copyWith( + finish: DateTime.now(), + ), + ); + } + } } diff --git a/mnemo_cards_backend/lib/database/daos/task_dao.dart b/mnemo_cards_backend/lib/database/daos/task_dao.dart index 5de13da..0de55ce 100644 --- a/mnemo_cards_backend/lib/database/daos/task_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/task_dao.dart @@ -47,15 +47,16 @@ class TaskDao extends DatabaseAccessor with _$TaskDaoMixin { return (select(db.userTasks)..where((ut) => ut.id.equals(id))).getSingleOrNull(); } + /// Получить все задачи пользователей (для системных задач) + Future> 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> 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 with _$TaskDaoMixin { updatedAt: Value(DateTime.now()), )); } + + /// Подсчитать задачи пользователя + Future 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 ==================== diff --git a/mnemo_cards_backend/lib/database/daos/user_dao.dart b/mnemo_cards_backend/lib/database/daos/user_dao.dart index e8baa9e..eea71a8 100644 --- a/mnemo_cards_backend/lib/database/daos/user_dao.dart +++ b/mnemo_cards_backend/lib/database/daos/user_dao.dart @@ -113,10 +113,19 @@ class UserDao extends DatabaseAccessor with _$UserDaoMixin { /// Получить пользователей с активной подпиской Stream> 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 with _$UserDaoMixin { .getSingleOrNull(); } + /// Получить токен по externalUserId + Future getTokenByExternalUserId(String externalUserId) { + return (select(tokens) + ..where((t) => t.externalUserId.equals(externalUserId)) + ..orderBy([(t) => OrderingTerm.desc(t.created)]) + ..limit(1) + ).getSingleOrNull(); + } + /// Получить токен пользователя Future getTokenByUserId(int userId) { return (select(tokens) diff --git a/mnemo_cards_backend/lib/discounts/discount_drift_extension.dart b/mnemo_cards_backend/lib/discounts/discount_drift_extension.dart new file mode 100644 index 0000000..32ba922 --- /dev/null +++ b/mnemo_cards_backend/lib/discounts/discount_drift_extension.dart @@ -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 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) { + return MnemoCardsProductDto.fromJson(p); + } + return null; + }).whereType().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; + } +} diff --git a/mnemo_cards_backend/lib/discounts/discounts_manager.dart b/mnemo_cards_backend/lib/discounts/discounts_manager.dart index 1dd5799..3b35397 100644 --- a/mnemo_cards_backend/lib/discounts/discounts_manager.dart +++ b/mnemo_cards_backend/lib/discounts/discounts_manager.dart @@ -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 applyDiscount({ required Iterable 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 changeCampaignStatus(DiscountCampaignModel campaign) async { + Future 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 applyCampaign(DiscountCampaignModel campaign) async { + Future 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 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 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> discountCampaignsForUser( - UserDataModel userModel, { + Future> 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> 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 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 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 { diff --git a/mnemo_cards_backend/lib/main.dart b/mnemo_cards_backend/lib/main.dart index 6ab07fd..75afaf6 100644 --- a/mnemo_cards_backend/lib/main.dart +++ b/mnemo_cards_backend/lib/main.dart @@ -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()), // TODO: enable after TestManager migration + CheckAdminsTask(database), getIt.get(), - AddFreePacks(getIt.get()), + AddFreePacks(getIt.get(), database), Backup(backupDir), - GeneratePromocodes(), - DiscountCampaignTask(getIt.get()), + GeneratePromocodes(database), + DiscountCampaignTask(getIt.get(), database), UpdateOnlineUsersTask(getIt.get()), - TasksSeederTask(), + TasksSeederTask(database), ]).init(); print('✅ Backend started successfully!'); diff --git a/mnemo_cards_backend/lib/packs/card_pack_drift_extension.dart b/mnemo_cards_backend/lib/packs/card_pack_drift_extension.dart index c9ff722..301743d 100644 --- a/mnemo_cards_backend/lib/packs/card_pack_drift_extension.dart +++ b/mnemo_cards_backend/lib/packs/card_pack_drift_extension.dart @@ -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 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 toDto(List cards, List 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 toDto(List 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), ); } } \ No newline at end of file diff --git a/mnemo_cards_backend/lib/packs/free_packs_distributor.dart b/mnemo_cards_backend/lib/packs/free_packs_distributor.dart index 2a1ea59..b4ece15 100644 --- a/mnemo_cards_backend/lib/packs/free_packs_distributor.dart +++ b/mnemo_cards_backend/lib/packs/free_packs_distributor.dart @@ -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> 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> getFreePacks() async { + final allPacks = await _db.packDao.getAllPacks(enabledOnly: true); + // Фильтруем паки с нулевой ценой или без цены + return allPacks.where((pack) => + pack.price == null || pack.price == 0 + ).toList(); } Future giveFreePacksToUser(UserModel user) async { @@ -27,11 +24,16 @@ class FreePacksDistributor { Future givePacksToUser( UserModel user, - List packs, - ) async => - isar.writeTxn( - () => (user.packs..addAll(packs)).save(), + List 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(); + } + } } diff --git a/mnemo_cards_backend/lib/packs/pack_dto_converter.dart b/mnemo_cards_backend/lib/packs/pack_dto_converter.dart index f86bdcf..b7945f9 100644 --- a/mnemo_cards_backend/lib/packs/pack_dto_converter.dart +++ b/mnemo_cards_backend/lib/packs/pack_dto_converter.dart @@ -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, diff --git a/mnemo_cards_backend/lib/packs/pack_manager.dart b/mnemo_cards_backend/lib/packs/pack_manager.dart index 912a9db..609d03c 100644 --- a/mnemo_cards_backend/lib/packs/pack_manager.dart +++ b/mnemo_cards_backend/lib/packs/pack_manager.dart @@ -53,13 +53,11 @@ class PackManager { } Future getVoice(int id) async { - // TODO: Implement in PackDao - return null; + return await _db.packDao.getVoiceById(id); } Future> getVoices(int cardId) async { - // TODO: Implement in PackDao - return []; + return await _db.packDao.getCardVoices(cardId); } Future 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 = []; for (final card in previewCards) { diff --git a/mnemo_cards_backend/lib/packs/pack_manager_extensions.dart b/mnemo_cards_backend/lib/packs/pack_manager_extensions.dart index 3bdc551..5fcc98e 100644 --- a/mnemo_cards_backend/lib/packs/pack_manager_extensions.dart +++ b/mnemo_cards_backend/lib/packs/pack_manager_extensions.dart @@ -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(); diff --git a/mnemo_cards_backend/lib/packs/products_price_resolver.dart b/mnemo_cards_backend/lib/packs/products_price_resolver.dart index 88f7029..31fa9e5 100644 --- a/mnemo_cards_backend/lib/packs/products_price_resolver.dart +++ b/mnemo_cards_backend/lib/packs/products_price_resolver.dart @@ -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 _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'; diff --git a/mnemo_cards_backend/lib/promo_codes/promo_codes_manager.dart b/mnemo_cards_backend/lib/promo_codes/promo_codes_manager.dart index 0c53b47..50cec70 100644 --- a/mnemo_cards_backend/lib/promo_codes/promo_codes_manager.dart +++ b/mnemo_cards_backend/lib/promo_codes/promo_codes_manager.dart @@ -35,11 +35,15 @@ class PromoCodesManager { promoCodes = codes.map((code) => code.code).toList(); } + final products = (campaign.products ?? []) + .map((p) => MnemoCardsProductDto.fromJson(p as Map)) + .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)) + .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 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?), 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 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; + final products = campaign['products'] as List; + + if (products.isNotEmpty) { + for (final productJson in products) { + final product = MnemoCardsProductDto.fromJson(productJson as Map); + await _paymentManager.grantProductToUser(userId, product); + } + } return {'success': true, 'code': code}; } - Future launchPromoCodesCampaign(dynamic campaign) async { - // TODO: Generate promo codes for campaign - // This involves generating codes based on template and generationSize - return false; + Future 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 = []; + 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(); } } \ No newline at end of file diff --git a/mnemo_cards_backend/lib/statistics/achievement_manager.dart b/mnemo_cards_backend/lib/statistics/achievement_manager.dart index b2e8ca2..3bf003b 100644 --- a/mnemo_cards_backend/lib/statistics/achievement_manager.dart +++ b/mnemo_cards_backend/lib/statistics/achievement_manager.dart @@ -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; diff --git a/mnemo_cards_backend/lib/statistics/session_tracker.dart b/mnemo_cards_backend/lib/statistics/session_tracker.dart index fc04bc2..23262bc 100644 --- a/mnemo_cards_backend/lib/statistics/session_tracker.dart +++ b/mnemo_cards_backend/lib/statistics/session_tracker.dart @@ -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 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 diff --git a/mnemo_cards_backend/lib/statistics/session_tracking_middleware.dart b/mnemo_cards_backend/lib/statistics/session_tracking_middleware.dart index 854f408..b09b1fc 100644 --- a/mnemo_cards_backend/lib/statistics/session_tracking_middleware.dart +++ b/mnemo_cards_backend/lib/statistics/session_tracking_middleware.dart @@ -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'); } } diff --git a/mnemo_cards_backend/lib/tasks/task_manager.dart b/mnemo_cards_backend/lib/tasks/task_manager.dart index 8750117..54ff6bc 100644 --- a/mnemo_cards_backend/lib/tasks/task_manager.dart +++ b/mnemo_cards_backend/lib/tasks/task_manager.dart @@ -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), ), ); }); @@ -77,21 +80,36 @@ class TaskManager { results: drift.Value({'completed': true, 'completedAt': now.toIso8601String()}), ), ); - - // TODO: Выдать награды пользователю }); } /// Получить прогресс выполнения задач пользователя - Future> getUserTaskProgress(int userId) async { - // TODO: Implement proper method in TaskDao - return []; // Placeholder + Future> getUserTaskProgress(int userId) async { + final tasks = await _db.taskDao.getUserTasks(userId); + final progresses = []; + + for (final task in tasks) { + final progress = await _db.taskDao.getTaskProgress(userId, task.id); + if (progress != null) { + progresses.add(progress); + } + } + + return progresses; } /// Получить категории задач Future> getTaskCategories() async { - // TODO: Extract unique categories from tasks - return ['app_internal', 'external', 'social']; + final tasks = await _db.taskDao.getAllUserTasks(); + final categories = {}; + + for (final task in tasks) { + if (task.type.isNotEmpty) { + categories.add(task.type); + } + } + + return categories.toList(); } /// Создать новую задачу для пользователя @@ -103,4 +121,9 @@ class TaskManager { Future updateUserTask(UserTask task) async { return await _db.taskDao.updateUserTask(task); } + + /// Подсчитать задачи пользователя + Future countUserTasks(int userId, {String? status}) async { + return await _db.taskDao.countUserTasks(userId, status: status); + } } \ No newline at end of file diff --git a/mnemo_cards_backend/lib/tests/test_manager.dart b/mnemo_cards_backend/lib/tests/test_manager.dart index d09676e..5a970c2 100644 --- a/mnemo_cards_backend/lib/tests/test_manager.dart +++ b/mnemo_cards_backend/lib/tests/test_manager.dart @@ -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 _customCreationTestData = []; - TestManager(this._db, this._packDtoConverter); + TestManager(this._db); Future _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 with 'attempts' key + final results = statistics.results ?? {}; + final attempts = (results['attempts'] as List?) ?? []; + + // Get words from the last attempt, or empty list if no attempts + final lastAttempt = attempts.isNotEmpty + ? attempts.last as Map? + : null; + final wordsJson = (lastAttempt?['words'] as List?) ?? []; + + // Convert words JSON to WordStatisticsDto + final words = wordsJson + .map((w) => WordStatisticsDto.fromJson(w as Map)) + .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 _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 refreshCustomCreationTestsData() async { // Load custom creation test data from database // For now, keep it simple - _customCreationTestData = []; } Future 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(), ); diff --git a/mnemo_cards_backend/lib/user/user_drift_extension.dart b/mnemo_cards_backend/lib/user/user_drift_extension.dart index 24cde69..9b1546a 100644 --- a/mnemo_cards_backend/lib/user/user_drift_extension.dart +++ b/mnemo_cards_backend/lib/user/user_drift_extension.dart @@ -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 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), ); } } \ No newline at end of file diff --git a/mnemo_cards_backend/lib/user/user_manager.dart b/mnemo_cards_backend/lib/user/user_manager.dart index 05d7f5b..82a5d0b 100644 --- a/mnemo_cards_backend/lib/user/user_manager.dart +++ b/mnemo_cards_backend/lib/user/user_manager.dart @@ -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 _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 fetchUser(int id) async { @@ -156,6 +156,112 @@ class UserManager { return (userModel, token); } - // TODO: Implement remaining methods as needed - // updateUserSettings, addTestStatistics, editUser, deleteUser + /// Обновить настройки пользователя + Future 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 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, где ключ 'attempts' содержит список попыток + final currentResults = existingStat?.results ?? {}; + final attemptsKey = 'attempts'; + final currentAttempts = (currentResults[attemptsKey] as List?) ?? []; + + final newAttempt = { + 'sessionToken': testStat.sessionToken, + 'words': testStat.words.words.map((w) => w.toJson()).toList(), + }; + + final updatedAttempts = [...currentAttempts, newAttempt]; + final updatedResults = { + ...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.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), + ), + ); + } } \ No newline at end of file diff --git a/mnemo_cards_backend/lib/user/user_manager_drift.dart b/mnemo_cards_backend/lib/user/user_manager_drift.dart index e19bb38..578b562 100644 --- a/mnemo_cards_backend/lib/user/user_manager_drift.dart +++ b/mnemo_cards_backend/lib/user/user_manager_drift.dart @@ -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 } \ No newline at end of file diff --git a/mnemo_cards_backend/public/open_api.yaml b/mnemo_cards_backend/public/open_api.yaml index 6111773..846f166 100644 --- a/mnemo_cards_backend/public/open_api.yaml +++ b/mnemo_cards_backend/public/open_api.yaml @@ -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 diff --git a/mnemo_cards_common_backend/lib/src/models/product_model.dart b/mnemo_cards_common_backend/lib/src/models/product_model.dart index c208e5a..625698e 100644 --- a/mnemo_cards_common_backend/lib/src/models/product_model.dart +++ b/mnemo_cards_common_backend/lib/src/models/product_model.dart @@ -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,