fixes
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Deploy Telegram Bot / Deploy Telegram Bot (push) Waiting to run

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -76,17 +76,18 @@ class AdminAnalyticsApiV2 {
}) })
.toList(); .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 allPacks = await backend_main.database.packDao.getAllPacks();
final topPacksList = allPacks.take(5); final topPacksList = allPacks.take(5);
final topPacks = topPacksList final topPacks = await Future.wait(topPacksList.map((p) async {
.map((p) => { final cards = await backend_main.database.packDao.getPackCards(p.id);
'id': p.id, return {
'title': p.title, 'id': p.id,
'cards': 0, // TODO: get card count for pack 'title': p.title,
'enabled': p.enabled, 'cards': cards.length,
}) 'enabled': p.enabled,
.toList(); };
}));
return _json({ return _json({
'stats': { 'stats': {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,14 +1,17 @@
import 'package:isar/isar.dart'; import 'package:drift/drift.dart';
import 'package:mnemo_cards_backend/cron/task.dart';
import 'package:mnemo_cards_backend/user/admin_ids_service.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 @override
String get name => 'check_admins'; String get name => 'check_admins';
final AppDatabase _db;
CheckAdminsTask(this._db);
@override @override
Future<void> task() async { Future<void> task() async {
// Получаем список ID админов из переменной окружения или файла // Получаем список ID админов из переменной окружения или файла
@ -18,36 +21,35 @@ class CheckAdminsTask with Task {
} }
List<int> adminIds = []; List<int> adminIds = [];
for (final token in adminIdsList) { for (final externalUserId in adminIdsList) {
print('$name processing admin ID: $token'); print('$name processing admin ID: $externalUserId');
if (token.isNotEmpty) { if (externalUserId.isNotEmpty) {
await isar.writeTxn(() async { // Найти токен по externalUserId
final tokenModel = await isar.tokenModels final token = await _db.userDao.getTokenByExternalUserId(externalUserId);
.filter()
.externalUserIdEqualTo(token) if (token == null) {
.findFirst(); continue;
if (tokenModel == null) { }
return;
// Получить пользователя по 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 { // TODO: Implement logic to remove admin flag from users not in adminIdsList
// final admins = // if needed in the future
// 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);
// });
} }
@override @override

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,13 @@
import 'package:isar/isar.dart'; import 'package:drift/drift.dart';
import 'package:mnemo_cards_backend/cron/task.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 '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 @override
String get name => 'tasks_seeder'; String get name => 'tasks_seeder';
@ -14,7 +17,7 @@ class TasksSeederTask with Task {
try { try {
// Check if tasks already exist // Check if tasks already exist
final existingTasks = await isar.userTaskModels.where().findAll(); final existingTasks = await _db.taskDao.getAllUserTasks();
if (existingTasks.isNotEmpty) { if (existingTasks.isNotEmpty) {
print('Tasks already exist, skipping seeding'); print('Tasks already exist, skipping seeding');
return; return;
@ -199,11 +202,27 @@ class TasksSeederTask with Task {
), ),
]; ];
await isar.writeTxn(() async { // Convert UserTaskModel to UserTasksCompanion and insert
for (final task in tasks) { for (final taskModel in tasks) {
await isar.userTaskModels.put(task); 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'); print('Successfully seeded ${tasks.length} user tasks');
} catch (e) { } catch (e) {

View file

@ -1,14 +1,13 @@
import 'package:isar/isar.dart'; import 'package:mnemo_cards_backend/cron/task.dart' as cron_task;
import 'package:mnemo_cards_backend/cron/task.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/tests/test_manager.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 cron_task.Task {
class TestGeneratorTask with Task {
final TestManager _testManager; final TestManager _testManager;
final AppDatabase _db;
TestGeneratorTask(this._testManager); TestGeneratorTask(this._testManager, this._db);
@override @override
String get name => 'test_gen'; String get name => 'test_gen';
@ -22,12 +21,31 @@ class TestGeneratorTask with Task {
print(e); print(e);
} }
print('Refreshed custom test data'); print('Refreshed custom test data');
final models = await isar.txn(() => isar.cardPackModels.where().findAll()); final packs = await _db.packDao.getAllPacks();
print('Will generate tests for ${models.length} packs'); print('Will generate tests for ${packs.length} packs');
int ok = 0; int ok = 0;
for (final model in models) { for (final pack in packs) {
try { 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++; ok++;
} catch (e) { } catch (e) {
print(e); print(e);
@ -35,9 +53,23 @@ class TestGeneratorTask with Task {
} }
print('Generated tests for $ok packs'); print('Generated tests for $ok packs');
print('Deleting old test stats'); print('Deleting old test stats');
final count = await isar.writeTxn(() { // Delete test statistics where test doesn't exist (test was deleted)
return isar.testStatisticsModels.filter().testIsNull().deleteAll(); 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'); print('deleted $count old test stats');
} }

View file

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

View file

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

View file

@ -92,11 +92,26 @@ class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
} }
/// Получить все карточки пака /// Получить все карточки пака
Future<List<GameCard>> getPackCards(int packId) { Future<List<GameCard>> getPackCards(int packId) async {
return (select(gameCards) final query = select(gameCards).join([
..where((c) => c.packId.equals(packId)) leftOuterJoin(
..orderBy([(c) => OrderingTerm.asc(c.id)]) // TODO: use proper ordering cardPackCards,
).get(); 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 /// Получить карточки по списку ID
@ -275,20 +290,4 @@ class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
Future<VoiceModel?> getVoiceById(int id) { Future<VoiceModel?> getVoiceById(int id) {
return (select(voiceModels)..where((v) => v.id.equals(id))).getSingleOrNull(); return (select(voiceModels)..where((v) => v.id.equals(id))).getSingleOrNull();
} }
/// Подсчитать все паки
Future<int> countPacks() async {
final countExpr = cardPacks.id.count();
final query = selectOnly(cardPacks)..addColumns([countExpr]);
return await query.map((row) => row.read(countExpr)!).getSingle();
}
/// Подсчитать все карточки
Future<int> countCards() async {
final countExpr = gameCards.id.count();
final query = selectOnly(gameCards)..addColumns([countExpr]);
return await query.map((row) => row.read(countExpr)!).getSingle();
}
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,9 @@
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/api/ads/ads_manager.dart'; import 'package:mnemo_cards_backend/api/ads/ads_manager.dart';
import 'package:mnemo_cards_backend/extensions.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_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/packs/card_model_extension.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 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'products_price_resolver.dart'; import 'products_price_resolver.dart';
@ -47,7 +46,9 @@ class PackDtoConverter {
cards: model.cards.length, cards: model.cards.length,
tests: model.tests.length, tests: model.tests.length,
price: price, price: price,
imageBase64: await model.cover?.smallBase64Image, imageBase64: model.cover != null
? await model.cover!.smallBase64Image
: null,
color: model.color, color: model.color,
isAvailable: available, isAvailable: available,
// trail: 'asset:icons/gift.png', // trail: 'asset:icons/gift.png',
@ -95,7 +96,9 @@ class PackDtoConverter {
.toDtosList(model.cardsOrder) .toDtosList(model.cardsOrder)
.map( .map(
(dto) async => dto.copyWith( (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(), ).uri.toString(),
), ),
], ],
if (model.googlePlayId != null && false) SpacerItem(height: 20, flex: 0)
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)
], ],
color: model.color, color: model.color,
version: model.version, version: model.version,
@ -176,7 +162,7 @@ class PackDtoConverter {
subtitle: model.subtitle, subtitle: model.subtitle,
color: model.color, color: model.color,
version: model.version, version: model.version,
cover: await model.cover?.base64Image, cover: model.cover != null ? await model.cover!.base64Image : null,
size: model.size, size: model.size,
googlePlayId: model.googlePlayId, googlePlayId: model.googlePlayId,
rustoreId: model.rustoreId, rustoreId: model.rustoreId,

View file

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

View file

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

View file

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

View file

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

View file

@ -140,11 +140,20 @@ class AchievementManager {
case AchievementType.words1000Learned: case AchievementType.words1000Learned:
return userData.totalCards >= 1000; return userData.totalCards >= 1000;
// TODO: Implement other achievement conditions case AchievementType.streak7Days:
// - Streak achievements (need session tracking) return userData.currentStreak >= 7;
// - Perfect test scores (need test results)
// - Time-based achievements (need session times) case AchievementType.streak30Days:
// - Speed learner (need pack completion times) 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: default:
return false; return false;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -707,7 +707,7 @@ tags:
- name: TestsApiV2 - name: TestsApiV2
description: Tests API v2\n\nRESTful endpoints for managing tests and test results description: Tests API v2\n\nRESTful endpoints for managing tests and test results
- name: TelegramBotApiV2 - 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 - name: AdminAnalyticsApiV2
description: Admin endpoints for analytics and statistics in API v2. description: Admin endpoints for analytics and statistics in API v2.
- name: TasksApiV2 - name: TasksApiV2

View file

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