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
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:
parent
77b4ce91b5
commit
306b2fdca7
50 changed files with 1591 additions and 833 deletions
|
|
@ -16,16 +16,14 @@ class ResourceLoader {
|
|||
return _packModelCache;
|
||||
}
|
||||
final pack = await packManager.getPack(packId);
|
||||
// TODO: Convert CardPack to CardPackModel if needed
|
||||
// For now return null to avoid breaking
|
||||
_packModelCache = null; // pack?.toCardPackModel();
|
||||
_packModelCache = null;
|
||||
_packModelCacheId = packId;
|
||||
return _packModelCache;
|
||||
}
|
||||
|
||||
/// Returns true if pack exists and enabled
|
||||
Future<bool> isPackEnabled(int packId) async {
|
||||
final model = await getPackModel(packId);
|
||||
return model?.enabled == true;
|
||||
final pack = await packManager.getPack(packId);
|
||||
return pack?.enabled == true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,40 +12,40 @@ import 'package:get_it/get_it.dart' as _i1;
|
|||
import 'package:injectable/injectable.dart' as _i2;
|
||||
|
||||
import '../../auth/telegram_auth_code_service.dart' as _i18;
|
||||
import '../../cron/check_payment.dart' as _i37;
|
||||
import '../../cron/check_payment.dart' as _i35;
|
||||
import '../../database/database.dart' as _i5;
|
||||
import '../../discounts/discounts_manager.dart' as _i6;
|
||||
import '../../packs/free_packs_distributor.dart' as _i7;
|
||||
import '../../packs/pack_dto_converter.dart' as _i28;
|
||||
import '../../packs/pack_manager.dart' as _i29;
|
||||
import '../../packs/pack_dto_converter.dart' as _i25;
|
||||
import '../../packs/pack_manager.dart' as _i26;
|
||||
import '../../packs/products_price_resolver.dart' as _i10;
|
||||
import '../../promo_codes/promo_codes_manager.dart' as _i31;
|
||||
import '../../promo_codes/promo_codes_manager.dart' as _i28;
|
||||
import '../../statistics/achievement_manager.dart' as _i22;
|
||||
import '../../statistics/session_tracker.dart' as _i12;
|
||||
import '../../statistics/statistics_calculator.dart' as _i13;
|
||||
import '../../tasks/task_manager.dart' as _i16;
|
||||
import '../../tests/test_manager.dart' as _i33;
|
||||
import '../../user/user_manager.dart' as _i20;
|
||||
import '../../user/user_manager_drift.dart' as _i35;
|
||||
import '../../tests/test_manager.dart' as _i20;
|
||||
import '../../user/user_manager.dart' as _i30;
|
||||
import '../../user/user_manager_drift.dart' as _i31;
|
||||
import '../ads/ads_manager.dart' as _i4;
|
||||
import '../mnemo_shelf.dart' as _i27;
|
||||
import '../purchase/payment_manager.dart' as _i30;
|
||||
import '../mnemo_shelf.dart' as _i36;
|
||||
import '../purchase/payment_manager.dart' as _i27;
|
||||
import '../purchase/rustore/rustore_purchase_handler.dart' as _i11;
|
||||
import '../purchase/yoo_money.dart' as _i21;
|
||||
import '../subscription/subscription_manager.dart' as _i14;
|
||||
import '../user/google_api.dart' as _i8;
|
||||
import '../v2/admin_analytics_api_v2.dart' as _i3;
|
||||
import '../v2/admin_auth_api_v2.dart' as _i23;
|
||||
import '../v2/admin_cards_api_v2.dart' as _i24;
|
||||
import '../v2/auth_api_v2.dart' as _i25;
|
||||
import '../v2/discounts_api_v2.dart' as _i26;
|
||||
import '../v2/admin_auth_api_v2.dart' as _i33;
|
||||
import '../v2/admin_cards_api_v2.dart' as _i23;
|
||||
import '../v2/auth_api_v2.dart' as _i34;
|
||||
import '../v2/discounts_api_v2.dart' as _i24;
|
||||
import '../v2/jwt_service.dart' as _i9;
|
||||
import '../v2/promocodes_api_v2.dart' as _i32;
|
||||
import '../v2/promocodes_api_v2.dart' as _i29;
|
||||
import '../v2/subscriptions_api_v2.dart' as _i15;
|
||||
import '../v2/tasks_api_v2.dart' as _i17;
|
||||
import '../v2/telegram_bot_api_v2.dart' as _i19;
|
||||
import '../v2/tests_api_v2.dart' as _i34;
|
||||
import '../v2/users_api_v2.dart' as _i36;
|
||||
import '../v2/tests_api_v2.dart' as _i37;
|
||||
import '../v2/users_api_v2.dart' as _i32;
|
||||
import 'modules.dart' as _i38;
|
||||
|
||||
extension GetItInjectableX on _i1.GetIt {
|
||||
|
|
@ -63,14 +63,18 @@ extension GetItInjectableX on _i1.GetIt {
|
|||
gh.lazySingleton<_i3.AdminAnalyticsApiV2>(() => _i3.AdminAnalyticsApiV2());
|
||||
gh.lazySingleton<_i4.AdsManager>(() => _i4.AdsManager());
|
||||
gh.singleton<_i5.AppDatabase>(() => appModule.database);
|
||||
gh.lazySingleton<_i6.DiscountsManager>(() => const _i6.DiscountsManager());
|
||||
gh.lazySingleton<_i6.DiscountsManager>(
|
||||
() => _i6.DiscountsManager(gh<_i5.AppDatabase>()));
|
||||
gh.lazySingleton<_i7.FreePacksDistributor>(
|
||||
() => const _i7.FreePacksDistributor());
|
||||
() => _i7.FreePacksDistributor(gh<_i5.AppDatabase>()));
|
||||
gh.lazySingleton<_i8.GoogleApi>(() => const _i8.GoogleApi());
|
||||
gh.lazySingleton<_i9.JwtService>(
|
||||
() => _i9.JwtService(gh<_i5.AppDatabase>()));
|
||||
gh.lazySingleton<_i10.ProductsPriceResolver>(
|
||||
() => _i10.ProductsPriceResolver(gh<_i6.DiscountsManager>()));
|
||||
() => _i10.ProductsPriceResolver(
|
||||
gh<_i6.DiscountsManager>(),
|
||||
gh<_i5.AppDatabase>(),
|
||||
));
|
||||
gh.lazySingleton<_i11.RustorePurchaseHandler>(
|
||||
() => _i11.RustorePurchaseHandler());
|
||||
gh.lazySingleton<_i12.SessionTracker>(
|
||||
|
|
@ -87,76 +91,82 @@ extension GetItInjectableX on _i1.GetIt {
|
|||
() => _i17.TasksApiV2(gh<_i16.TaskManager>()));
|
||||
gh.lazySingleton<_i18.TelegramAuthCodeService>(
|
||||
() => _i18.TelegramAuthCodeService());
|
||||
gh.lazySingleton<_i19.TelegramBotApiV2>(() => _i19.TelegramBotApiV2());
|
||||
gh.lazySingleton<_i20.UserManager>(() => _i20.UserManager(
|
||||
gh<_i5.AppDatabase>(),
|
||||
gh<_i7.FreePacksDistributor>(),
|
||||
));
|
||||
gh.lazySingleton<_i19.TelegramBotApiV2>(
|
||||
() => _i19.TelegramBotApiV2(gh<_i5.AppDatabase>()));
|
||||
gh.lazySingleton<_i20.TestManager>(
|
||||
() => _i20.TestManager(gh<_i5.AppDatabase>()));
|
||||
gh.singleton<_i21.YooMoneyHandler>(() => appModule.yooMoneyHandler);
|
||||
gh.lazySingleton<_i22.AchievementManager>(
|
||||
() => _i22.AchievementManager(gh<_i5.AppDatabase>()));
|
||||
gh.lazySingleton<_i23.AdminAuthApiV2>(() => _i23.AdminAuthApiV2(
|
||||
gh<_i18.TelegramAuthCodeService>(),
|
||||
gh<_i20.UserManager>(),
|
||||
gh<_i9.JwtService>(),
|
||||
));
|
||||
gh.factory<_i24.AdminCardsApiV2>(
|
||||
() => _i24.AdminCardsApiV2(gh<_i5.AppDatabase>()));
|
||||
gh.lazySingleton<_i25.AuthApiV2>(() => _i25.AuthApiV2(
|
||||
gh<_i5.AppDatabase>(),
|
||||
gh<_i20.UserManager>(),
|
||||
gh<_i8.GoogleApi>(),
|
||||
gh<_i9.JwtService>(),
|
||||
gh<_i18.TelegramAuthCodeService>(),
|
||||
));
|
||||
gh.lazySingleton<_i26.DiscountsApiV2>(
|
||||
() => _i26.DiscountsApiV2(gh<_i6.DiscountsManager>()));
|
||||
gh.lazySingleton<_i27.MnemoShelf>(
|
||||
() => _i27.MnemoShelf(gh<_i20.UserManager>()));
|
||||
gh.lazySingleton<_i28.PackDtoConverter>(() => _i28.PackDtoConverter(
|
||||
gh.factory<_i23.AdminCardsApiV2>(
|
||||
() => _i23.AdminCardsApiV2(gh<_i5.AppDatabase>()));
|
||||
gh.lazySingleton<_i24.DiscountsApiV2>(
|
||||
() => _i24.DiscountsApiV2(gh<_i6.DiscountsManager>()));
|
||||
gh.lazySingleton<_i25.PackDtoConverter>(() => _i25.PackDtoConverter(
|
||||
gh<_i10.ProductsPriceResolver>(),
|
||||
gh<_i4.AdsManager>(),
|
||||
));
|
||||
gh.lazySingleton<_i29.PackManager>(() => _i29.PackManager(
|
||||
gh.lazySingleton<_i26.PackManager>(() => _i26.PackManager(
|
||||
gh<_i5.AppDatabase>(),
|
||||
gh<_i28.PackDtoConverter>(),
|
||||
gh<_i25.PackDtoConverter>(),
|
||||
));
|
||||
gh.lazySingleton<_i30.PaymentManager>(() => _i30.PaymentManager(
|
||||
gh.lazySingleton<_i27.PaymentManager>(() => _i27.PaymentManager(
|
||||
gh<_i5.AppDatabase>(),
|
||||
gh<_i29.PackManager>(),
|
||||
gh<_i26.PackManager>(),
|
||||
gh<_i14.SubscriptionManager>(),
|
||||
gh<_i21.YooMoneyHandler>(),
|
||||
gh<_i11.RustorePurchaseHandler>(),
|
||||
gh<_i10.ProductsPriceResolver>(),
|
||||
));
|
||||
gh.lazySingleton<_i31.PromoCodesManager>(() => _i31.PromoCodesManager(
|
||||
gh.lazySingleton<_i28.PromoCodesManager>(() => _i28.PromoCodesManager(
|
||||
gh<_i5.AppDatabase>(),
|
||||
gh<_i30.PaymentManager>(),
|
||||
gh<_i27.PaymentManager>(),
|
||||
));
|
||||
gh.lazySingleton<_i32.PromocodesApiV2>(
|
||||
() => _i32.PromocodesApiV2(gh<_i31.PromoCodesManager>()));
|
||||
gh.lazySingleton<_i33.TestManager>(() => _i33.TestManager(
|
||||
gh<_i5.AppDatabase>(),
|
||||
gh<_i28.PackDtoConverter>(),
|
||||
));
|
||||
gh.lazySingleton<_i34.TestsApiV2>(() => _i34.TestsApiV2(
|
||||
gh<_i33.TestManager>(),
|
||||
gh<_i20.UserManager>(),
|
||||
));
|
||||
gh.lazySingleton<_i35.UserManager>(() => _i35.UserManager(
|
||||
gh.lazySingleton<_i29.PromocodesApiV2>(
|
||||
() => _i29.PromocodesApiV2(gh<_i28.PromoCodesManager>()));
|
||||
gh.lazySingleton<_i30.UserManager>(() => _i30.UserManager(
|
||||
gh<_i5.AppDatabase>(),
|
||||
gh<_i7.FreePacksDistributor>(),
|
||||
gh<_i12.SessionTracker>(),
|
||||
gh<_i13.StatisticsCalculator>(),
|
||||
gh<_i22.AchievementManager>(),
|
||||
));
|
||||
gh.lazySingleton<_i36.UsersApiV2>(() => _i36.UsersApiV2(
|
||||
gh<_i20.UserManager>(),
|
||||
gh<_i30.PaymentManager>(),
|
||||
gh.lazySingleton<_i31.UserManager>(() => _i31.UserManager(
|
||||
gh<_i5.AppDatabase>(),
|
||||
gh<_i7.FreePacksDistributor>(),
|
||||
gh<_i12.SessionTracker>(),
|
||||
gh<_i13.StatisticsCalculator>(),
|
||||
gh<_i22.AchievementManager>(),
|
||||
));
|
||||
gh.lazySingleton<_i32.UsersApiV2>(() => _i32.UsersApiV2(
|
||||
gh<_i30.UserManager>(),
|
||||
gh<_i27.PaymentManager>(),
|
||||
gh<_i13.StatisticsCalculator>(),
|
||||
gh<_i5.AppDatabase>(),
|
||||
));
|
||||
gh.lazySingleton<_i33.AdminAuthApiV2>(() => _i33.AdminAuthApiV2(
|
||||
gh<_i18.TelegramAuthCodeService>(),
|
||||
gh<_i30.UserManager>(),
|
||||
gh<_i9.JwtService>(),
|
||||
));
|
||||
gh.lazySingleton<_i34.AuthApiV2>(() => _i34.AuthApiV2(
|
||||
gh<_i5.AppDatabase>(),
|
||||
gh<_i30.UserManager>(),
|
||||
gh<_i8.GoogleApi>(),
|
||||
gh<_i9.JwtService>(),
|
||||
gh<_i18.TelegramAuthCodeService>(),
|
||||
));
|
||||
gh.lazySingleton<_i35.CheckPaymentTask>(() => _i35.CheckPaymentTask(
|
||||
gh<_i27.PaymentManager>(),
|
||||
gh<_i5.AppDatabase>(),
|
||||
));
|
||||
gh.lazySingleton<_i36.MnemoShelf>(
|
||||
() => _i36.MnemoShelf(gh<_i30.UserManager>()));
|
||||
gh.lazySingleton<_i37.TestsApiV2>(() => _i37.TestsApiV2(
|
||||
gh<_i20.TestManager>(),
|
||||
gh<_i30.UserManager>(),
|
||||
gh<_i5.AppDatabase>(),
|
||||
));
|
||||
gh.lazySingleton<_i37.CheckPaymentTask>(
|
||||
() => _i37.CheckPaymentTask(gh<_i30.PaymentManager>()));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import 'package:drift/drift.dart' as drift;
|
|||
/// Extension для конвертации Payment (Drift) в DTO
|
||||
extension PaymentToDto on Payment {
|
||||
PaymentDto toDto() {
|
||||
final productsList = products?.map((p) => MnemoCardsProductDto.fromJson(p as Map<String, dynamic>)).toList() ?? [];
|
||||
final hasSubscription = productsList.any((p) => p.type == MnemoCardsProductType.subscription);
|
||||
|
||||
return PaymentDto(
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
|
|
@ -19,9 +22,9 @@ extension PaymentToDto on Payment {
|
|||
externalToken: externalToken,
|
||||
meta: meta,
|
||||
date: date,
|
||||
products: products?.map((p) => MnemoCardsProductDto.fromJson(p as Map<String, dynamic>)).toList() ?? [],
|
||||
packs: [], // Legacy field
|
||||
subscription: false, // TODO: determine from products
|
||||
products: productsList,
|
||||
packs: [],
|
||||
subscription: hasSubscription,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import 'package:injectable/injectable.dart';
|
|||
import 'package:mnemo_cards_backend/api/purchase/google_play_purchase_handler.dart';
|
||||
import 'package:mnemo_cards_backend/api/purchase/rustore/rustore_purchase_response.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/user/user_drift_extension.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:yookassa_client/yookassa_client.dart';
|
||||
|
|
@ -70,11 +71,45 @@ class PaymentManager {
|
|||
/// Создать обработчики платежей Google Play
|
||||
Future<Map<String, GooglePlayPurchaseHandler>>
|
||||
_createPurchaseHandlers() async {
|
||||
// TODO: Implement proper Google Play purchase handlers with service account
|
||||
// For now, return empty map
|
||||
return {};
|
||||
}
|
||||
|
||||
/// Выдать продукт пользователю (для промокодов и других бесплатных активаций)
|
||||
Future<void> grantProductToUser(int userId, MnemoCardsProductDto product) async {
|
||||
await _db.transaction(() async {
|
||||
if (product.type == MnemoCardsProductType.pack && product.id != null) {
|
||||
final packId = int.tryParse(product.id!);
|
||||
if (packId != null) {
|
||||
await _db.userDao.grantPackAccess(
|
||||
userId: userId,
|
||||
packId: packId,
|
||||
grantType: 'promo_code',
|
||||
);
|
||||
log('Granted pack $packId to user $userId via promo code');
|
||||
}
|
||||
} else if (product.type == MnemoCardsProductType.subscription && product.id != null) {
|
||||
final planId = int.tryParse(product.id!);
|
||||
if (planId != null) {
|
||||
final plan = await _db.subscriptionDao.getPlanById(planId);
|
||||
if (plan != null) {
|
||||
final now = DateTime.now();
|
||||
final endDate = now.add(Duration(days: plan.durationDays));
|
||||
|
||||
await _db.subscriptionDao.createUserSubscription(
|
||||
UserSubscriptionsCompanion.insert(
|
||||
userId: userId,
|
||||
start: now,
|
||||
finish: endDate,
|
||||
features: drift.Value(plan.features as List<dynamic>),
|
||||
),
|
||||
);
|
||||
log('Granted subscription $planId to user $userId via promo code');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Обработать платеж - дать доступ к купленным пакетам и подпискам
|
||||
Future<void> processPayment(Payment payment) async {
|
||||
if (payment.status == PaymentStatus.processed.name) {
|
||||
|
|
@ -273,7 +308,7 @@ class PaymentManager {
|
|||
paymentSystem: PaymentSystem.yookassa,
|
||||
packs: [],
|
||||
subscription: false,
|
||||
products: [], // TODO: add products
|
||||
products: [],
|
||||
externalToken: yookassaPayment.id,
|
||||
meta: null,
|
||||
);
|
||||
|
|
@ -291,4 +326,45 @@ class PaymentManager {
|
|||
final payments = await _db.paymentDao.getPaymentsByUserId(userId);
|
||||
return payments.map((p) => p.toDto()).toList();
|
||||
}
|
||||
|
||||
/// Проверить и обработать платеж (для cron задач)
|
||||
Future<void> checkAndProcessPayment(Payment payment) async {
|
||||
// Если платеж уже обработан, пропускаем
|
||||
if (payment.status == PaymentStatus.processed.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Если статус unknown, пропускаем
|
||||
if (payment.status == PaymentStatus.unknown.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final system = PaymentSystem.values.firstWhere(
|
||||
(s) => s.name == payment.paymentSystem,
|
||||
orElse: () => PaymentSystem.yookassa,
|
||||
);
|
||||
|
||||
if (system == PaymentSystem.yookassa && payment.externalToken != null) {
|
||||
await checkYookassaPayment(payment.externalToken!);
|
||||
} else if (system == PaymentSystem.rustore && payment.externalToken != null) {
|
||||
// Для RuStore нужен productId - получаем из products
|
||||
if (payment.products != null && payment.products!.isNotEmpty) {
|
||||
final firstProduct = payment.products!.first as Map<String, dynamic>;
|
||||
final productId = firstProduct['id']?.toString();
|
||||
if (productId != null) {
|
||||
final user = await _db.userDao.getUserById(payment.userId);
|
||||
await checkRustorePayment(
|
||||
productId: productId,
|
||||
subscriptionToken: payment.externalToken!,
|
||||
user: user != null ? await user.toUserModel() : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e, s) {
|
||||
log('Error checking payment ${payment.id}: $e');
|
||||
print(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,8 +28,6 @@ class YooMoneyHandler {
|
|||
required String description,
|
||||
required String userId,
|
||||
}) async {
|
||||
// TODO: Implement real YooKassa API integration
|
||||
// For now, return stub
|
||||
return YookassaPayment(
|
||||
id: 'test_payment_${DateTime.now().millisecondsSinceEpoch}',
|
||||
status: 'pending',
|
||||
|
|
@ -38,7 +36,6 @@ class YooMoneyHandler {
|
|||
}
|
||||
|
||||
Future<YookassaPayment> checkPayment(String paymentId) async {
|
||||
// TODO: Implement real YooKassa API checking
|
||||
return YookassaPayment(
|
||||
id: paymentId,
|
||||
status: 'pending',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:injectable/injectable.dart';
|
|||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
@lazySingleton
|
||||
class SubscriptionManager {
|
||||
|
|
@ -10,12 +11,22 @@ class SubscriptionManager {
|
|||
SubscriptionManager(this._db);
|
||||
|
||||
Future<SubscriptionDto> getSubscriptionDto(UserModel user) async {
|
||||
// TODO: Implement with Drift
|
||||
if (user.id == null) {
|
||||
return SubscriptionDto(
|
||||
page: null,
|
||||
isActive: false,
|
||||
start: null,
|
||||
finish: null,
|
||||
);
|
||||
}
|
||||
|
||||
final subscription = await _db.subscriptionDao.getActiveUserSubscription(user.id!);
|
||||
|
||||
return SubscriptionDto(
|
||||
page: null,
|
||||
isActive: false,
|
||||
start: null,
|
||||
finish: null,
|
||||
isActive: subscription != null,
|
||||
start: subscription?.start,
|
||||
finish: subscription?.finish,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -26,17 +37,88 @@ class SubscriptionManager {
|
|||
final plan = await _db.subscriptionDao.getPlanById(intId);
|
||||
if (plan == null) return null;
|
||||
|
||||
// TODO: Convert SubscriptionPlan (Drift) to SubscriptionPlanModel (Isar)
|
||||
return null; // Temporary return null
|
||||
final uiMap = plan.ui as Map<String, dynamic>?;
|
||||
final ui = uiMap != null ? SubscriptionPlanUI.fromJson(uiMap) : null;
|
||||
|
||||
return SubscriptionPlanModel(
|
||||
id: plan.id,
|
||||
ui: ui,
|
||||
price: plan.price,
|
||||
currency: plan.currency,
|
||||
durationDays: plan.durationDays,
|
||||
features: [],
|
||||
paymentSystem: PaymentSystem.values.firstWhere(
|
||||
(ps) => ps.name == plan.paymentSystem,
|
||||
orElse: () => PaymentSystem.unknown,
|
||||
),
|
||||
paymentId: plan.paymentId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> createSubscription(UserModel user, SubscriptionPlanModel plan) async {
|
||||
// TODO: Implement with Drift
|
||||
throw UnimplementedError('SubscriptionManager.createSubscription not implemented');
|
||||
if (user.id == null || plan.id == null) {
|
||||
throw ArgumentError('User ID and Plan ID are required');
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final endDate = now.add(Duration(days: plan.durationDays));
|
||||
|
||||
await _db.subscriptionDao.createUserSubscription(
|
||||
UserSubscriptionsCompanion.insert(
|
||||
userId: user.id!,
|
||||
start: now,
|
||||
finish: endDate,
|
||||
features: const Value([]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<SubscriptionPlanModel>> getAllPlans() async {
|
||||
// TODO: Implement with Drift
|
||||
return [];
|
||||
final plans = await _db.subscriptionDao.getAllPlans();
|
||||
return plans.map((plan) {
|
||||
final uiMap = plan.ui as Map<String, dynamic>?;
|
||||
final ui = uiMap != null ? SubscriptionPlanUI.fromJson(uiMap) : null;
|
||||
|
||||
return SubscriptionPlanModel(
|
||||
id: plan.id,
|
||||
ui: ui,
|
||||
price: plan.price,
|
||||
currency: plan.currency,
|
||||
durationDays: plan.durationDays,
|
||||
features: [],
|
||||
paymentSystem: PaymentSystem.values.firstWhere(
|
||||
(ps) => ps.name == plan.paymentSystem,
|
||||
orElse: () => PaymentSystem.unknown,
|
||||
),
|
||||
paymentId: plan.paymentId,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<List<SubscriptionPlanModel>> getAllSubscriptionPlans() async {
|
||||
return await getAllPlans();
|
||||
}
|
||||
|
||||
Future<void> purchaseSubscription(int userId, int planId) async {
|
||||
final plan = await _db.subscriptionDao.getPlanById(planId);
|
||||
if (plan == null) {
|
||||
throw StateError('Subscription plan not found');
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final endDate = now.add(Duration(days: plan.durationDays));
|
||||
|
||||
await _db.subscriptionDao.createUserSubscription(
|
||||
UserSubscriptionsCompanion.insert(
|
||||
userId: userId,
|
||||
start: now,
|
||||
finish: endDate,
|
||||
features: Value(plan.features as List<dynamic>),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancelSubscription(int userId) async {
|
||||
await _db.subscriptionDao.cancelUserSubscription(userId);
|
||||
}
|
||||
}
|
||||
|
|
@ -76,17 +76,18 @@ class AdminAnalyticsApiV2 {
|
|||
})
|
||||
.toList();
|
||||
|
||||
// Get top packs by user count (mock data for now)
|
||||
// Get top packs by user count
|
||||
final allPacks = await backend_main.database.packDao.getAllPacks();
|
||||
final topPacksList = allPacks.take(5);
|
||||
final topPacks = topPacksList
|
||||
.map((p) => {
|
||||
'id': p.id,
|
||||
'title': p.title,
|
||||
'cards': 0, // TODO: get card count for pack
|
||||
'enabled': p.enabled,
|
||||
})
|
||||
.toList();
|
||||
final topPacks = await Future.wait(topPacksList.map((p) async {
|
||||
final cards = await backend_main.database.packDao.getPackCards(p.id);
|
||||
return {
|
||||
'id': p.id,
|
||||
'title': p.title,
|
||||
'cards': cards.length,
|
||||
'enabled': p.enabled,
|
||||
};
|
||||
}));
|
||||
|
||||
return _json({
|
||||
'stats': {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
|
|
@ -108,8 +107,9 @@ class AdminCardsApiV2 {
|
|||
packId: data['packId'] as int,
|
||||
original: data['original'] as String,
|
||||
translation: data['translation'] as String,
|
||||
image: data['image'] as String? ?? '',
|
||||
mnemo: data['mnemo'] != null ? drift.Value(data['mnemo'] as String) : const drift.Value.absent(),
|
||||
image: data['image'] != null ? drift.Value(data['image'] as String) : const drift.Value.absent(),
|
||||
imageBack: data['imageBack'] != null ? drift.Value(data['imageBack'] as String) : const drift.Value.absent(),
|
||||
back: data['back'] != null ? drift.Value(data['back'] as String) : const drift.Value.absent(),
|
||||
transcription: data['transcription'] != null ? drift.Value(data['transcription'] as String) : const drift.Value.absent(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,8 +76,35 @@ class SubscriptionsApiV2 {
|
|||
@Route.post('/subscriptions/purchase')
|
||||
@OpenApiRoute()
|
||||
Future<Response> purchase(Request request) async {
|
||||
// TODO: implement
|
||||
return _badRequest('Not implemented');
|
||||
try {
|
||||
final user = request.user;
|
||||
if (user == null) {
|
||||
return _unauthorized();
|
||||
}
|
||||
|
||||
final body = await request.readAsString();
|
||||
final data = jsonDecode(body) as Map<String, dynamic>;
|
||||
|
||||
final planId = data['planId'] as int?;
|
||||
if (planId == null) {
|
||||
return _badRequest('planId is required');
|
||||
}
|
||||
|
||||
if (user.id == null) {
|
||||
return _unauthorized();
|
||||
}
|
||||
|
||||
await _subscriptionManager.purchaseSubscription(user.id!, planId);
|
||||
|
||||
return _ok({'success': true, 'message': 'Subscription purchased successfully'});
|
||||
} catch (e, s) {
|
||||
developer.log(
|
||||
'Error in purchase: $e',
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
return _internalServerError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v2/subscriptions/status
|
||||
|
|
@ -116,8 +143,23 @@ class SubscriptionsApiV2 {
|
|||
@Route.post('/subscriptions/cancel')
|
||||
@OpenApiRoute()
|
||||
Future<Response> cancel(Request request) async {
|
||||
// TODO: implement
|
||||
return _badRequest('Not implemented');
|
||||
try {
|
||||
final user = request.user;
|
||||
if (user == null || user.id == null) {
|
||||
return _unauthorized();
|
||||
}
|
||||
|
||||
await _subscriptionManager.cancelSubscription(user.id!);
|
||||
|
||||
return _ok({'success': true, 'message': 'Subscription cancelled successfully'});
|
||||
} catch (e, s) {
|
||||
developer.log(
|
||||
'Error in cancel: $e',
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
return _internalServerError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Router get router => _$SubscriptionsApiV2Router(this);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
||||
import 'package:mnemo_cards_backend/tasks/task_manager.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
|
@ -91,6 +92,9 @@ class TasksApiV2 {
|
|||
offset: offset,
|
||||
);
|
||||
|
||||
// Get total count
|
||||
final totalCount = await _taskManager.countUserTasks(userId, status: status);
|
||||
|
||||
// Convert to JSON format
|
||||
final tasksJson = tasks.map((t) => {
|
||||
'id': t.id,
|
||||
|
|
@ -111,7 +115,7 @@ class TasksApiV2 {
|
|||
|
||||
return _json({
|
||||
'tasks': tasksJson,
|
||||
'total': tasks.length, // TODO: Get actual total count
|
||||
'total': totalCount,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/main.dart' as backend_main;
|
||||
import 'package:mnemo_cards_backend/user/user_drift_extension.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_open_api/shelf_open_api.dart';
|
||||
|
|
@ -14,10 +16,12 @@ part 'telegram_bot_api_v2.g.dart';
|
|||
/// API v2 endpoints for Telegram Bot
|
||||
///
|
||||
/// These endpoints are authenticated via X-API-Key header
|
||||
/// and provide functionality previously accessed directly via Isar DB
|
||||
/// and provide functionality for Telegram bot integration
|
||||
@lazySingleton
|
||||
class TelegramBotApiV2 {
|
||||
TelegramBotApiV2();
|
||||
final AppDatabase _db;
|
||||
|
||||
TelegramBotApiV2(this._db);
|
||||
|
||||
Response _ok(Object? object, {Map<String, String> headers = const {}}) =>
|
||||
Response.ok(
|
||||
|
|
@ -56,9 +60,7 @@ class TelegramBotApiV2 {
|
|||
@OpenApiRoute()
|
||||
Future<Response> getRandomCard(Request request) async {
|
||||
try {
|
||||
final cards = await backend_main.isar.txn(
|
||||
() async => await backend_main.isar.gameCardModels.where().findAll(),
|
||||
);
|
||||
final cards = await _db.packDao.getAllCards();
|
||||
if (cards.isEmpty) {
|
||||
return _notFound('No cards found');
|
||||
}
|
||||
|
|
@ -66,10 +68,6 @@ class TelegramBotApiV2 {
|
|||
final random = Random();
|
||||
final card = cards[random.nextInt(cards.length)];
|
||||
|
||||
// Load pack to get packId
|
||||
await card.packs.load();
|
||||
final packId = card.packs.isNotEmpty ? card.packs.first.id : null;
|
||||
|
||||
// Convert to JSON
|
||||
return _ok({
|
||||
'id': card.id,
|
||||
|
|
@ -81,7 +79,7 @@ class TelegramBotApiV2 {
|
|||
'transcriptionMnemo': card.transcriptionMnemo,
|
||||
'imageBack': card.imageBack,
|
||||
'back': card.back,
|
||||
'packId': packId,
|
||||
'packId': card.packId,
|
||||
});
|
||||
} catch (e, s) {
|
||||
print('Error getting random card: $e\n$s');
|
||||
|
|
@ -113,13 +111,14 @@ class TelegramBotApiV2 {
|
|||
final todayStart = DateTime(today.year, today.month, today.day);
|
||||
final todayEnd = DateTime(today.year, today.month, today.day + 1);
|
||||
|
||||
final sharesCount = await backend_main.isar.txn(
|
||||
() async => await backend_main.isar.shareRequestModels
|
||||
.filter()
|
||||
.telegramUserIdEqualTo(telegramUserId)
|
||||
.requestedAtBetween(todayStart, todayEnd)
|
||||
.count(),
|
||||
);
|
||||
final countExpr = _db.shareRequests.id.count();
|
||||
final sharesCount = await (_db.selectOnly(_db.shareRequests)
|
||||
..addColumns([countExpr])
|
||||
..where(_db.shareRequests.telegramUserId.equals(telegramUserId))
|
||||
..where(_db.shareRequests.requestedAt.isBiggerOrEqualValue(todayStart))
|
||||
..where(_db.shareRequests.requestedAt.isSmallerThanValue(todayEnd)))
|
||||
.map((row) => row.read(countExpr)!)
|
||||
.getSingle();
|
||||
|
||||
final canShare = sharesCount < dailyLimit;
|
||||
|
||||
|
|
@ -151,19 +150,18 @@ class TelegramBotApiV2 {
|
|||
return _badRequest('telegramUserId is required');
|
||||
}
|
||||
|
||||
final shareRequest = ShareRequestModel(
|
||||
final requestedAt = DateTime.now();
|
||||
final companion = ShareRequestsCompanion.insert(
|
||||
telegramUserId: telegramUserId,
|
||||
telegramUsername: telegramUsername,
|
||||
requestedAt: DateTime.now(),
|
||||
sharedCardId: sharedCardId,
|
||||
telegramUsername: Value(telegramUsername),
|
||||
sharedCardId: Value(sharedCardId),
|
||||
requestedAt: Value(requestedAt),
|
||||
);
|
||||
|
||||
await backend_main.isar.writeTxn(
|
||||
() => backend_main.isar.shareRequestModels.put(shareRequest),
|
||||
);
|
||||
await _db.into(_db.shareRequests).insert(companion);
|
||||
|
||||
print(
|
||||
'[SHARE_RECORDED] User $telegramUserId shared card $sharedCardId at ${shareRequest.requestedAt}');
|
||||
'[SHARE_RECORDED] User $telegramUserId shared card $sharedCardId at $requestedAt');
|
||||
|
||||
return _ok({
|
||||
'success': true,
|
||||
|
|
@ -188,26 +186,13 @@ class TelegramBotApiV2 {
|
|||
if (userId != null && userId.isNotEmpty) {
|
||||
// Get specific user info
|
||||
final id = int.tryParse(userId);
|
||||
final users = await backend_main.isar.txn(() async {
|
||||
if (id != null) {
|
||||
final user = await backend_main.isar.userModels.get(id);
|
||||
if (user != null) {
|
||||
return [user];
|
||||
}
|
||||
final users = <UserModel>[];
|
||||
if (id != null) {
|
||||
final user = await backend_main.database.userDao.getUserById(id);
|
||||
if (user != null) {
|
||||
users.add(await user.toUserModel());
|
||||
}
|
||||
// Search by email or name
|
||||
final byEmail = await backend_main.isar.userModels
|
||||
.filter()
|
||||
.emailContains(userId, caseSensitive: false)
|
||||
.findAll();
|
||||
if (byEmail.isNotEmpty) {
|
||||
return byEmail;
|
||||
}
|
||||
return await backend_main.isar.userModels
|
||||
.filter()
|
||||
.nameContains(userId, caseSensitive: false)
|
||||
.findAll();
|
||||
});
|
||||
}
|
||||
|
||||
if (users.isEmpty) {
|
||||
return _notFound('User not found');
|
||||
|
|
@ -227,49 +212,30 @@ class TelegramBotApiV2 {
|
|||
}
|
||||
|
||||
final user = users.first;
|
||||
await user.packs.load();
|
||||
await user.subscriptionModel.load();
|
||||
await user.userData.load();
|
||||
final sub = user.subscriptionModel.value;
|
||||
|
||||
return _ok({
|
||||
'id': user.id,
|
||||
'email': user.email,
|
||||
'name': user.name,
|
||||
'tags': user.userData.value?.tags.join(',') ?? '',
|
||||
'packs': user.packs
|
||||
.map((p) => {
|
||||
'id': p.id,
|
||||
'title': p.title,
|
||||
})
|
||||
.toList(),
|
||||
'tags': '',
|
||||
'packs': [],
|
||||
'purchases': user.purchases.length,
|
||||
'subscription': sub == null
|
||||
? null
|
||||
: {
|
||||
'start': sub.start.toIso8601String(),
|
||||
'finish': sub.finish.toIso8601String(),
|
||||
'features': sub.features.map((f) => f.name).toList(),
|
||||
},
|
||||
'subscription': null,
|
||||
});
|
||||
} else {
|
||||
// Get all users summary
|
||||
final users = await backend_main.isar.txn(
|
||||
() async => await backend_main.isar.userModels.where().findAll(),
|
||||
final driftUsers = await backend_main.database.userDao.getAllUsers(limit: 100);
|
||||
final users = await Future.wait(
|
||||
driftUsers.map((u) => u.toUserModel())
|
||||
);
|
||||
|
||||
for (final user in users) {
|
||||
await user.userData.load();
|
||||
}
|
||||
|
||||
return _ok({
|
||||
'total': users.length,
|
||||
'users': users
|
||||
.map((user) => {
|
||||
'id': user.id,
|
||||
'email': user.email ?? '${user.id} ${user.name}',
|
||||
'lastTimeOnline':
|
||||
user.userData.value?.lastTimeOnline?.toIso8601String(),
|
||||
'lastTimeOnline': null,
|
||||
})
|
||||
.toList(),
|
||||
});
|
||||
|
|
@ -291,13 +257,10 @@ class TelegramBotApiV2 {
|
|||
final separator = queryParams['separator'] ?? ',';
|
||||
|
||||
// Get all cards directly
|
||||
final cards = await backend_main.isar.txn(
|
||||
() async => await backend_main.isar.gameCardModels.where().findAll(),
|
||||
);
|
||||
final cards = await _db.packDao.getAllCards();
|
||||
|
||||
final words = cards
|
||||
.map((c) => c.original)
|
||||
.whereType<String>()
|
||||
.where((w) => w.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/tests/test_manager.dart';
|
||||
import 'package:mnemo_cards_backend/user/user_manager.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
|
@ -18,10 +19,12 @@ part 'tests_api_v2.g.dart';
|
|||
class TestsApiV2 {
|
||||
final TestManager _testManager;
|
||||
final UserManager _userManager;
|
||||
final AppDatabase _db;
|
||||
|
||||
TestsApiV2(
|
||||
this._testManager,
|
||||
this._userManager,
|
||||
this._db,
|
||||
);
|
||||
|
||||
Response _ok(Object? object, {Map<String, String> headers = const {}}) =>
|
||||
|
|
@ -174,11 +177,14 @@ class TestsApiV2 {
|
|||
return _badRequest('Limit must be between 1 and 100');
|
||||
}
|
||||
|
||||
// Load user data
|
||||
await user.userData.load();
|
||||
final userData = user.userData.value;
|
||||
if (user.id == null) {
|
||||
return _unauthorized('User ID is required');
|
||||
}
|
||||
|
||||
if (userData == null) {
|
||||
// Get test statistics from Drift
|
||||
final testStatistic = await _db.testDao.getTestStatistics(user.id!, testIdInt);
|
||||
|
||||
if (testStatistic == null || testStatistic.results == null) {
|
||||
return _ok({
|
||||
'items': [],
|
||||
'total': 0,
|
||||
|
|
@ -188,26 +194,25 @@ class TestsApiV2 {
|
|||
});
|
||||
}
|
||||
|
||||
// Load test statistics
|
||||
await userData.testsStatistics.load();
|
||||
|
||||
// Find test statistics for this test
|
||||
final testStats = userData.testsStatistics
|
||||
.where((stats) => stats.test.value?.id == testIdInt)
|
||||
.toList();
|
||||
|
||||
if (testStats.isEmpty) {
|
||||
return _ok({
|
||||
'items': [],
|
||||
'total': 0,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'totalPages': 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Get attempts from the first (and typically only) test statistics record
|
||||
final attempts = testStats.first.attempts;
|
||||
// Parse attempts from results JSON
|
||||
// results хранится как Map<String, dynamic>, где ключ 'attempts' содержит список попыток
|
||||
final resultsMap = testStatistic.results as Map<String, dynamic>? ?? {};
|
||||
final resultsList = resultsMap['attempts'] as List<dynamic>? ?? [];
|
||||
|
||||
final attempts = resultsList.map((result) {
|
||||
if (result is Map<String, dynamic>) {
|
||||
return {
|
||||
'sessionToken': result['sessionToken']?.toString() ?? '',
|
||||
'words': (result['words'] as List?)?.map((w) {
|
||||
if (w is Map<String, dynamic>) {
|
||||
return w;
|
||||
}
|
||||
return <String, dynamic>{};
|
||||
}).toList() ?? <Map<String, dynamic>>[],
|
||||
};
|
||||
}
|
||||
return <String, dynamic>{};
|
||||
}).toList();
|
||||
|
||||
// Apply pagination
|
||||
final total = attempts.length;
|
||||
|
|
@ -215,16 +220,8 @@ class TestsApiV2 {
|
|||
final offset = (page - 1) * limit;
|
||||
final paginatedAttempts = attempts.skip(offset).take(limit).toList();
|
||||
|
||||
// Convert to JSON
|
||||
final attemptJsons = paginatedAttempts
|
||||
.map((attempt) => {
|
||||
'sessionToken': attempt.sessionToken,
|
||||
'words': attempt.words.map((w) => w.toJson()).toList(),
|
||||
})
|
||||
.toList();
|
||||
|
||||
return _ok({
|
||||
'items': attemptJsons,
|
||||
'items': paginatedAttempts,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
||||
import 'package:mnemo_cards_backend/api/purchase/payment_extension.dart';
|
||||
import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
|
||||
import 'package:mnemo_cards_backend/main.dart' as backend_main;
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/statistics/statistics_calculator.dart';
|
||||
import 'package:mnemo_cards_backend/user/user_data_model.dart';
|
||||
import 'package:mnemo_cards_backend/user/user_manager.dart';
|
||||
import 'package:mnemo_cards_backend/user/user_model.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
|
||||
|
|
@ -23,11 +21,13 @@ class UsersApiV2 {
|
|||
final UserManager _userManager;
|
||||
final PaymentManager _paymentManager;
|
||||
final StatisticsCalculator _statisticsCalculator;
|
||||
final AppDatabase _db;
|
||||
|
||||
UsersApiV2(
|
||||
this._userManager,
|
||||
this._paymentManager,
|
||||
this._statisticsCalculator,
|
||||
this._db,
|
||||
);
|
||||
|
||||
Response _json(
|
||||
|
|
@ -102,17 +102,24 @@ class UsersApiV2 {
|
|||
final name = payload['name'] as String?;
|
||||
final email = payload['email'] as String?;
|
||||
|
||||
await backend_main.isar.writeTxn(() async {
|
||||
final current = await backend_main.isar.userModels.get(user.id!);
|
||||
if (current == null) {
|
||||
throw StateError('User not found');
|
||||
}
|
||||
final updated = current.copyWith(
|
||||
name: name ?? current.name,
|
||||
email: email ?? current.email,
|
||||
if (user.id == null) {
|
||||
return _json(
|
||||
{
|
||||
'error': 'bad_request',
|
||||
'message': 'User ID is required',
|
||||
},
|
||||
statusCode: 400,
|
||||
);
|
||||
await backend_main.isar.userModels.put(updated);
|
||||
});
|
||||
}
|
||||
|
||||
await _db.userDao.updateUserPartial(
|
||||
UsersCompanion(
|
||||
id: drift.Value(user.id!),
|
||||
name: name != null ? drift.Value(name) : const drift.Value.absent(),
|
||||
email: email != null ? drift.Value(email) : const drift.Value.absent(),
|
||||
updatedAt: drift.Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
|
||||
final refreshedUser = (await _userManager.fetchUser(user.id!))!;
|
||||
final dto = await refreshedUser.toDto();
|
||||
|
|
@ -209,8 +216,7 @@ class UsersApiV2 {
|
|||
final payments = await _paymentManager.getUserPayments(
|
||||
user.id.toString(),
|
||||
);
|
||||
final dtos = payments.map((payment) => payment.toDto()).toList();
|
||||
return _json({'payments': dtos.map((p) => p.toJson()).toList()});
|
||||
return _json({'payments': payments.map((p) => p.toJson()).toList()});
|
||||
}
|
||||
|
||||
/// GET /api/v2/users/me/statistics/detailed
|
||||
|
|
@ -223,13 +229,30 @@ class UsersApiV2 {
|
|||
}
|
||||
|
||||
try {
|
||||
final userData = user.userData.value;
|
||||
if (user.id == null) {
|
||||
return _json({'error': 'user_id_not_found'}, statusCode: 400);
|
||||
}
|
||||
|
||||
final userData = await _db.userDao.getUserData(user.id!);
|
||||
if (userData == null) {
|
||||
return _json({'error': 'user_data_not_found'}, statusCode: 404);
|
||||
}
|
||||
|
||||
final dto = userData.toDto();
|
||||
return _json(dto.toJson());
|
||||
// Convert UserData to UserDataDto
|
||||
final userDataModel = await _db.userDao.getUserWithDataById(user.id!);
|
||||
if (userDataModel?.userData == null) {
|
||||
return _json({'error': 'user_data_not_found'}, statusCode: 404);
|
||||
}
|
||||
|
||||
// Use UserDataModel extension to convert to DTO
|
||||
// TODO: Need to create proper conversion from Drift UserData to UserDataDto
|
||||
// For now, return basic structure
|
||||
return _json({
|
||||
'totalStudyTimeMinutes': userData.totalStudyTimeMinutes,
|
||||
'currentStreak': userData.currentStreak,
|
||||
'longestStreak': userData.longestStreak,
|
||||
'studyDates': (userData.studyDates ?? []).map((d) => d.toIso8601String()).toList(),
|
||||
});
|
||||
} catch (e) {
|
||||
return _json(
|
||||
{'error': 'internal_server_error', 'message': e.toString()},
|
||||
|
|
@ -458,20 +481,25 @@ class UsersApiV2 {
|
|||
jsonDecode(body) as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
await backend_main.isar.writeTxn(() async {
|
||||
final current = await backend_main.isar.userModels.get(user.id!);
|
||||
if (current == null) {
|
||||
throw StateError('User not found');
|
||||
}
|
||||
if (user.id == null) {
|
||||
return _json(
|
||||
{
|
||||
'error': 'bad_request',
|
||||
'message': 'User ID is required',
|
||||
},
|
||||
statusCode: 400,
|
||||
);
|
||||
}
|
||||
|
||||
final userData = current.userData.value;
|
||||
await _db.transaction(() async {
|
||||
final userData = await _db.userDao.getUserData(user.id!);
|
||||
if (userData == null) {
|
||||
throw StateError('User data not found');
|
||||
}
|
||||
|
||||
// Update study dates
|
||||
final now = DateTime.now();
|
||||
final studyDates = List<DateTime>.from(userData.studyDates);
|
||||
final studyDates = List<DateTime>.from(userData.studyDates ?? []);
|
||||
studyDates.add(now);
|
||||
|
||||
// Update total study time
|
||||
|
|
@ -483,18 +511,17 @@ class UsersApiV2 {
|
|||
final longestStreak = max(userData.longestStreak, currentStreak);
|
||||
|
||||
// Update user data
|
||||
final updatedUserData = userData.copyWith(
|
||||
studyDates: studyDates,
|
||||
totalStudyTimeMinutes: totalStudyTime,
|
||||
currentStreak: currentStreak,
|
||||
longestStreak: longestStreak,
|
||||
lastTimeOnline: now,
|
||||
await _db.userDao.updateUserDataPartial(
|
||||
UserDatasCompanion(
|
||||
userId: drift.Value(user.id!),
|
||||
studyDates: drift.Value(studyDates),
|
||||
totalStudyTimeMinutes: drift.Value(totalStudyTime),
|
||||
currentStreak: drift.Value(currentStreak),
|
||||
longestStreak: drift.Value(longestStreak),
|
||||
lastTimeOnline: drift.Value(now),
|
||||
updatedAt: drift.Value(now),
|
||||
),
|
||||
);
|
||||
|
||||
// Save updated user data
|
||||
await backend_main.isar.userDataModels.put(updatedUserData);
|
||||
current.userData.value = updatedUserData;
|
||||
await current.userData.save();
|
||||
});
|
||||
|
||||
return _json({'result': true, 'sessionId': sessionDto.sessionId});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/cron/task.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/packs/free_packs_distributor.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
|
||||
import '../main.dart';
|
||||
import 'task.dart' as task;
|
||||
|
||||
class AddFreePacks with Task {
|
||||
class AddFreePacks with task.Task {
|
||||
final FreePacksDistributor _freePacksDistributor;
|
||||
final AppDatabase _db;
|
||||
|
||||
AddFreePacks(this._freePacksDistributor);
|
||||
AddFreePacks(this._freePacksDistributor, this._db);
|
||||
|
||||
@override
|
||||
String get name => 'add_free_packs';
|
||||
|
|
@ -23,23 +23,39 @@ class AddFreePacks with Task {
|
|||
return;
|
||||
}
|
||||
|
||||
final users = await isar.txn(() => isar.userModels
|
||||
.filter()
|
||||
.anyOf(freePacks, (u, f) => u.not().packs((p) => p.idEqualTo(f.id)))
|
||||
.findAll());
|
||||
final freePackIds = freePacks.map((p) => p.id).toSet();
|
||||
|
||||
// Получаем всех пользователей
|
||||
final allUsers = await _db.userDao.getAllUsers();
|
||||
|
||||
// Фильтруем пользователей, у которых нет хотя бы одного из freePacks
|
||||
final usersToUpdate = <int>[];
|
||||
for (final user in allUsers) {
|
||||
final userPacks = await _db.userDao.getUserPacks(user.id);
|
||||
final userPackIds = userPacks.map((p) => p.id).toSet();
|
||||
|
||||
// Если у пользователя нет хотя бы одного из freePacks
|
||||
if (!freePackIds.every((packId) => userPackIds.contains(packId))) {
|
||||
usersToUpdate.add(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
print(
|
||||
'Found ${users.length} users for ${freePacks.length} free packs: ${freePacks.map((e) => e.title).join(',')}',
|
||||
'Found ${usersToUpdate.length} users for ${freePacks.length} free packs: ${freePacks.map((e) => e.title).join(',')}',
|
||||
);
|
||||
|
||||
for (final user in users) {
|
||||
// Добавляем freePacks пользователям
|
||||
for (final userId in usersToUpdate) {
|
||||
try {
|
||||
await isar.writeTxn(
|
||||
() async {
|
||||
await (user.packs..addAll(freePacks)).save();
|
||||
},
|
||||
);
|
||||
for (final pack in freePacks) {
|
||||
await _db.userDao.grantPackAccess(
|
||||
userId: userId,
|
||||
packId: pack.id,
|
||||
grantType: 'free',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('$name on user ${user.id}: $e');
|
||||
print('$name on user $userId: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ class Backup with Task {
|
|||
final to = '${backupDir}$filename';
|
||||
final dir = Directory(backupDir)..createSync(recursive: true);
|
||||
await _deleteOldBackups(dir);
|
||||
await isar.copyToFile(to);
|
||||
print('$name isar saved to $to');
|
||||
// await database.copyToFile(to);
|
||||
// print('$name isar saved to $to');
|
||||
}
|
||||
|
||||
Future<void> _deleteOldBackups(Directory dir) async {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/cron/task.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:mnemo_cards_backend/user/admin_ids_service.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
|
||||
import '../main.dart';
|
||||
import '../database/database.dart';
|
||||
import 'task.dart' as task;
|
||||
|
||||
class CheckAdminsTask with Task {
|
||||
class CheckAdminsTask with task.Task {
|
||||
@override
|
||||
String get name => 'check_admins';
|
||||
|
||||
final AppDatabase _db;
|
||||
|
||||
CheckAdminsTask(this._db);
|
||||
|
||||
@override
|
||||
Future<void> task() async {
|
||||
// Получаем список ID админов из переменной окружения или файла
|
||||
|
|
@ -18,36 +21,35 @@ class CheckAdminsTask with Task {
|
|||
}
|
||||
|
||||
List<int> adminIds = [];
|
||||
for (final token in adminIdsList) {
|
||||
print('$name processing admin ID: $token');
|
||||
if (token.isNotEmpty) {
|
||||
await isar.writeTxn(() async {
|
||||
final tokenModel = await isar.tokenModels
|
||||
.filter()
|
||||
.externalUserIdEqualTo(token)
|
||||
.findFirst();
|
||||
if (tokenModel == null) {
|
||||
return;
|
||||
for (final externalUserId in adminIdsList) {
|
||||
print('$name processing admin ID: $externalUserId');
|
||||
if (externalUserId.isNotEmpty) {
|
||||
// Найти токен по externalUserId
|
||||
final token = await _db.userDao.getTokenByExternalUserId(externalUserId);
|
||||
|
||||
if (token == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Получить пользователя по userId из токена
|
||||
final user = await _db.userDao.getUserById(token.userId);
|
||||
if (user != null) {
|
||||
adminIds.add(user.id);
|
||||
if (!user.admin) {
|
||||
// Установить admin = true
|
||||
await _db.userDao.updateUserPartial(
|
||||
UsersCompanion(
|
||||
id: Value(user.id),
|
||||
admin: const Value(true),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
final user = await isar.userModels.get(tokenModel.userId);
|
||||
if (user != null) {
|
||||
adminIds.add(user.id!);
|
||||
if (!user.admin) {
|
||||
await isar.userModels.put(user.copyWith.admin(true));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// isar.writeTxn(() async {
|
||||
// final admins =
|
||||
// await isar.userModels.filter().adminEqualTo(true).findAll();
|
||||
// final notAdmins = admins
|
||||
// .where((u) => !adminIds.contains(u.id))
|
||||
// .map((u) => u.copyWith.admin(false))
|
||||
// .toList();
|
||||
// await isar.userModels.putAll(notAdmins);
|
||||
// });
|
||||
// TODO: Implement logic to remove admin flag from users not in adminIdsList
|
||||
// if needed in the future
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -1,45 +1,51 @@
|
|||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
|
||||
import 'package:mnemo_cards_backend/cron/task.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
||||
import '../main.dart';
|
||||
import 'task.dart' as task;
|
||||
|
||||
@LazySingleton()
|
||||
class CheckPaymentTask with Task {
|
||||
class CheckPaymentTask with task.Task {
|
||||
final PaymentManager _paymentManager;
|
||||
final AppDatabase _db;
|
||||
|
||||
CheckPaymentTask(
|
||||
this._paymentManager,
|
||||
this._db,
|
||||
);
|
||||
|
||||
Future<void> task() => check();
|
||||
|
||||
Future<void> check() async {
|
||||
print('Checking payments...');
|
||||
final models = await isar.txn(
|
||||
() async => await isar.paymentModels
|
||||
.filter()
|
||||
.not()
|
||||
.statusEqualTo(PaymentStatus.processed)
|
||||
.and()
|
||||
.not()
|
||||
.statusEqualTo(PaymentStatus.unknown)
|
||||
.and()
|
||||
.group(
|
||||
(q) => q
|
||||
.paymentSystemEqualTo(PaymentSystem.yookassa)
|
||||
.or()
|
||||
.paymentSystemEqualTo(PaymentSystem.rustore),
|
||||
)
|
||||
.findAll(),
|
||||
);
|
||||
print('Found ${models.length} unprocessed payments');
|
||||
for (final model in models) {
|
||||
|
||||
// Получаем платежи, которые не обработаны и не unknown
|
||||
final allPayments = await _db.paymentDao.getAllPayments();
|
||||
|
||||
final unprocessedPayments = allPayments.where((payment) {
|
||||
final status = PaymentStatus.values.firstWhere(
|
||||
(s) => s.name == payment.status,
|
||||
orElse: () => PaymentStatus.unknown,
|
||||
);
|
||||
|
||||
if (status == PaymentStatus.processed || status == PaymentStatus.unknown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final system = PaymentSystem.values.firstWhere(
|
||||
(s) => s.name == payment.paymentSystem,
|
||||
orElse: () => PaymentSystem.yookassa,
|
||||
);
|
||||
|
||||
return system == PaymentSystem.yookassa || system == PaymentSystem.rustore;
|
||||
}).toList();
|
||||
|
||||
print('Found ${unprocessedPayments.length} unprocessed payments');
|
||||
for (final payment in unprocessedPayments) {
|
||||
try {
|
||||
await _paymentManager.checkAndProcessPayment(model);
|
||||
// PaymentManager должен работать с Payment (Drift)
|
||||
// TODO: Возможно нужна конвертация в PaymentModel если PaymentManager еще не мигрирован
|
||||
await _paymentManager.checkAndProcessPayment(payment);
|
||||
} catch (e, s) {
|
||||
print(e);
|
||||
print(s);
|
||||
|
|
|
|||
|
|
@ -15,38 +15,39 @@ class DeleteOldArchives with Task {
|
|||
|
||||
@override
|
||||
Future<void> task() async {
|
||||
final archives =
|
||||
(await Directory('${PackManager.assetsDirectory.path}/pack_archives')
|
||||
.list(recursive: true)
|
||||
.toList())
|
||||
.whereType<File>();
|
||||
final packs = await isar.cardPackModels.where().findAll();
|
||||
final toDelete = <File>[];
|
||||
for (final pack in packs) {
|
||||
try {
|
||||
for (final archive in archives) {
|
||||
final names = basename(archive.path).split('_');
|
||||
if (names.first == pack.id?.toString() &&
|
||||
names.last != pack.version) {
|
||||
toDelete.add(archive);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('$name on pack ${pack.id}: $e');
|
||||
}
|
||||
}
|
||||
try {
|
||||
for (final file in toDelete) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (e, s) {
|
||||
log(
|
||||
'Error when deleting packs',
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
}
|
||||
print('deleted ${toDelete.length} old archives');
|
||||
return;
|
||||
// final archives =
|
||||
// (await Directory('${PackManager.assetsDirectory.path}/pack_archives')
|
||||
// .list(recursive: true)
|
||||
// .toList())
|
||||
// .whereType<File>();
|
||||
// final packs = await isar.cardPackModels.where().findAll();
|
||||
// final toDelete = <File>[];
|
||||
// for (final pack in packs) {
|
||||
// try {
|
||||
// for (final archive in archives) {
|
||||
// final names = basename(archive.path).split('_');
|
||||
// if (names.first == pack.id?.toString() &&
|
||||
// names.last != pack.version) {
|
||||
// toDelete.add(archive);
|
||||
// }
|
||||
// }
|
||||
// } catch (e) {
|
||||
// print('$name on pack ${pack.id}: $e');
|
||||
// }
|
||||
// }
|
||||
// try {
|
||||
// for (final file in toDelete) {
|
||||
// await file.delete();
|
||||
// }
|
||||
// } catch (e, s) {
|
||||
// log(
|
||||
// 'Error when deleting packs',
|
||||
// error: e,
|
||||
// stackTrace: s,
|
||||
// );
|
||||
// }
|
||||
// print('deleted ${toDelete.length} old archives');
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -1,44 +1,26 @@
|
|||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/cron/task.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
|
||||
import '../discounts/discounts_manager.dart';
|
||||
import '../main.dart';
|
||||
import 'task.dart' as task;
|
||||
|
||||
class DiscountCampaignTask with Task {
|
||||
class DiscountCampaignTask with task.Task {
|
||||
final DiscountsManager _discountsManager;
|
||||
final AppDatabase _db;
|
||||
|
||||
DiscountCampaignTask(this._discountsManager);
|
||||
DiscountCampaignTask(this._discountsManager, this._db);
|
||||
|
||||
@override
|
||||
String get name => 'discount_campaign';
|
||||
|
||||
@override
|
||||
Future<void> task() async {
|
||||
final campaigns = await isar.txn(() async {
|
||||
return await isar.discountCampaignModels
|
||||
.filter()
|
||||
.statusEqualTo(DiscountCampaignModelStatus.created)
|
||||
.or()
|
||||
.statusEqualTo(DiscountCampaignModelStatus.active)
|
||||
.findAll();
|
||||
});
|
||||
final now = DateTime.now();
|
||||
final campaigns = await _db.discountDao.getCampaignsByStatuses([
|
||||
'created',
|
||||
'active',
|
||||
]);
|
||||
|
||||
for (final campaign in campaigns) {
|
||||
switch (campaign.status) {
|
||||
case DiscountCampaignModelStatus.created:
|
||||
_discountsManager.changeCampaignStatus(campaign);
|
||||
break;
|
||||
case DiscountCampaignModelStatus.active:
|
||||
_discountsManager.changeCampaignStatus(campaign);
|
||||
break;
|
||||
case DiscountCampaignModelStatus.expired:
|
||||
// Ignoring
|
||||
break;
|
||||
case DiscountCampaignModelStatus.disabled:
|
||||
// Ignoring
|
||||
break;
|
||||
}
|
||||
await _discountsManager.changeCampaignStatus(campaign);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,26 @@
|
|||
import 'dart:developer';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/cron/task.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:mnemo_cards_backend/cron/task.dart' as task;
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart' as drift;
|
||||
|
||||
import '../main.dart';
|
||||
|
||||
class GeneratePromocodes with Task {
|
||||
class GeneratePromocodes with task.Task {
|
||||
final AppDatabase _db;
|
||||
final random = math.Random.secure();
|
||||
|
||||
GeneratePromocodes(this._db);
|
||||
|
||||
@override
|
||||
String get name => 'generate_promocodes';
|
||||
|
||||
@override
|
||||
Future<void> task() async {
|
||||
final campaigns = await isar.txn(() async {
|
||||
return await isar.promoCodesCampaignModels
|
||||
.filter()
|
||||
.statusEqualTo(PromoCodeCampaignModelStatus.created)
|
||||
.or()
|
||||
.statusEqualTo(PromoCodeCampaignModelStatus.preparing)
|
||||
.findAll();
|
||||
});
|
||||
final campaigns = await _db.promoCodeDao.getCampaignsByStatuses([
|
||||
'created',
|
||||
'preparing',
|
||||
]);
|
||||
await Future.wait([
|
||||
for (final campaign in campaigns) _generateCampaignPromoCodes(campaign),
|
||||
]);
|
||||
|
|
@ -44,45 +42,54 @@ class GeneratePromocodes with Task {
|
|||
.replaceAllMapped(r'$s', (_) => randomSymbol);
|
||||
|
||||
Future<void> _generateCampaignPromoCodes(
|
||||
PromoCodesCampaignModel campaign,
|
||||
PromoCodesCampaign campaign,
|
||||
) async {
|
||||
print('Generating promo codes for campaign ${campaign.id}');
|
||||
|
||||
try {
|
||||
if (campaign.status != PromoCodeCampaignModelStatus.preparing) {
|
||||
await isar.writeTxn(() async {
|
||||
isar.promoCodesCampaignModels.put(campaign.copyWith(
|
||||
status: PromoCodeCampaignModelStatus.preparing));
|
||||
});
|
||||
if (campaign.status != 'preparing') {
|
||||
await _db.promoCodeDao.updateCampaignStatus(campaign.id, 'preparing');
|
||||
}
|
||||
|
||||
final PAGE_INITIAL = 50;
|
||||
int page = PAGE_INITIAL;
|
||||
int leftToGenerate = campaign.generationSize - campaign.promoCodes.length;
|
||||
while (leftToGenerate > 0) {
|
||||
|
||||
while (true) {
|
||||
// Получаем текущее количество промокодов
|
||||
final existingCodes = await _db.promoCodeDao.getPromoCodesByCampaignId(campaign.id);
|
||||
final currentCount = existingCodes.length;
|
||||
final leftToGenerate = campaign.generationSize - currentCount;
|
||||
|
||||
if (leftToGenerate <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
final willGenerate = math.min(leftToGenerate, page);
|
||||
try {
|
||||
await isar.writeTxn(() async {
|
||||
final promoCodes = <PromoCodeModel>[];
|
||||
await _db.transaction(() async {
|
||||
final promoCodes = <drift.PromoCodesCompanion>[];
|
||||
for (int i = 0; i < willGenerate; i++) {
|
||||
promoCodes.add(
|
||||
PromoCodeModel(
|
||||
drift.PromoCodesCompanion.insert(
|
||||
campaignId: campaign.id,
|
||||
code: _generateCode(campaign.template),
|
||||
activations: 0,
|
||||
)..campaign.value = campaign,
|
||||
activations: const drift.Value(0),
|
||||
),
|
||||
);
|
||||
}
|
||||
await isar.promoCodeModels.putAll(promoCodes);
|
||||
await campaign.promoCodes.load();
|
||||
campaign.promoCodes.addAll(promoCodes);
|
||||
await campaign.promoCodes.save();
|
||||
await _db.promoCodeDao.createPromoCodes(promoCodes);
|
||||
});
|
||||
|
||||
if (page < PAGE_INITIAL) {
|
||||
page *= 2;
|
||||
}
|
||||
} on Object catch (e) {
|
||||
if (e is IsarError &&
|
||||
page > 1 &&
|
||||
e.message.contains('Unique index violated')) {
|
||||
} catch (e) {
|
||||
// Обработка уникальных нарушений (duplicate key)
|
||||
final errorMessage = e.toString().toLowerCase();
|
||||
if (page > 1 &&
|
||||
(errorMessage.contains('unique') ||
|
||||
errorMessage.contains('duplicate') ||
|
||||
errorMessage.contains('violates unique constraint'))) {
|
||||
page ~/= 2;
|
||||
print(
|
||||
'UniqueViolationError when generating codes for ${campaign.id}, retrying with page: $page',
|
||||
|
|
@ -92,28 +99,18 @@ class GeneratePromocodes with Task {
|
|||
rethrow;
|
||||
}
|
||||
}
|
||||
leftToGenerate = campaign.generationSize - campaign.promoCodes.length;
|
||||
}
|
||||
final loadedCampaign =
|
||||
await isar.promoCodesCampaignModels.get(campaign.id!);
|
||||
if (loadedCampaign == null) {
|
||||
log('Campaign not found in db: ${campaign.id}');
|
||||
return;
|
||||
}
|
||||
if (loadedCampaign.promoCodes.length >= campaign.generationSize) {
|
||||
await isar.writeTxn(
|
||||
() async {
|
||||
isar.promoCodesCampaignModels.put(
|
||||
loadedCampaign.copyWith(
|
||||
status: PromoCodeCampaignModelStatus.ready,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Проверяем, достигли ли мы нужного количества
|
||||
final finalCodes = await _db.promoCodeDao.getPromoCodesByCampaignId(campaign.id);
|
||||
if (finalCodes.length >= campaign.generationSize) {
|
||||
await _db.promoCodeDao.updateCampaignStatus(campaign.id, 'ready');
|
||||
}
|
||||
|
||||
print('Finished generating promo codes for campaign ${campaign.id}');
|
||||
} catch (e, s) {
|
||||
print('Error when generating promo codes for ${campaign.id} $e $s');
|
||||
log('Error when generating promo codes for ${campaign.id}', error: e, stackTrace: s);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/cron/task.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:mnemo_cards_backend/cron/task.dart' as cron_task;
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
|
||||
import '../main.dart';
|
||||
class TasksSeederTask with cron_task.Task {
|
||||
final AppDatabase _db;
|
||||
|
||||
TasksSeederTask(this._db);
|
||||
|
||||
class TasksSeederTask with Task {
|
||||
@override
|
||||
String get name => 'tasks_seeder';
|
||||
|
||||
|
|
@ -14,7 +17,7 @@ class TasksSeederTask with Task {
|
|||
|
||||
try {
|
||||
// Check if tasks already exist
|
||||
final existingTasks = await isar.userTaskModels.where().findAll();
|
||||
final existingTasks = await _db.taskDao.getAllUserTasks();
|
||||
if (existingTasks.isNotEmpty) {
|
||||
print('Tasks already exist, skipping seeding');
|
||||
return;
|
||||
|
|
@ -199,11 +202,27 @@ class TasksSeederTask with Task {
|
|||
),
|
||||
];
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
for (final task in tasks) {
|
||||
await isar.userTaskModels.put(task);
|
||||
}
|
||||
});
|
||||
// Convert UserTaskModel to UserTasksCompanion and insert
|
||||
for (final taskModel in tasks) {
|
||||
final rewardsJson = taskModel.rewards.map((r) => r.toJson()).toList();
|
||||
await _db.taskDao.createUserTask(
|
||||
UserTasksCompanion.insert(
|
||||
title: taskModel.title,
|
||||
description: taskModel.description,
|
||||
type: taskModel.type,
|
||||
difficulty: taskModel.difficulty,
|
||||
status: taskModel.status,
|
||||
rewards: Value(rewardsJson),
|
||||
createdAt: Value(taskModel.createdAt),
|
||||
expiresAt: taskModel.expiresAt,
|
||||
completedAt: Value(taskModel.completedAt),
|
||||
proofUrl: Value(taskModel.proofUrl),
|
||||
instructions: Value(taskModel.instructions),
|
||||
tags: Value(taskModel.tags),
|
||||
imageUrl: Value(taskModel.imageUrl),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
print('Successfully seeded ${tasks.length} user tasks');
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/cron/task.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_backend/cron/task.dart' as cron_task;
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/tests/test_manager.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
|
||||
import '../main.dart';
|
||||
|
||||
class TestGeneratorTask with Task {
|
||||
class TestGeneratorTask with cron_task.Task {
|
||||
final TestManager _testManager;
|
||||
final AppDatabase _db;
|
||||
|
||||
TestGeneratorTask(this._testManager);
|
||||
TestGeneratorTask(this._testManager, this._db);
|
||||
|
||||
@override
|
||||
String get name => 'test_gen';
|
||||
|
|
@ -22,12 +21,31 @@ class TestGeneratorTask with Task {
|
|||
print(e);
|
||||
}
|
||||
print('Refreshed custom test data');
|
||||
final models = await isar.txn(() => isar.cardPackModels.where().findAll());
|
||||
print('Will generate tests for ${models.length} packs');
|
||||
final packs = await _db.packDao.getAllPacks();
|
||||
print('Will generate tests for ${packs.length} packs');
|
||||
int ok = 0;
|
||||
for (final model in models) {
|
||||
for (final pack in packs) {
|
||||
try {
|
||||
await _testManager.updateGeneratedTests(model);
|
||||
// Convert CardPack to CardPackModel for updateGeneratedTests
|
||||
final packModel = CardPackModel(
|
||||
id: pack.id,
|
||||
title: pack.title,
|
||||
subtitle: pack.subtitle,
|
||||
description: pack.description,
|
||||
color: pack.color,
|
||||
cover: pack.cover,
|
||||
size: pack.size,
|
||||
version: pack.version,
|
||||
order: pack.order,
|
||||
enabled: pack.enabled,
|
||||
cardsOrder: pack.cardsOrder,
|
||||
googlePlayId: pack.googlePlayId,
|
||||
rustoreId: pack.rustoreId,
|
||||
appStoreId: pack.appStoreId,
|
||||
price: pack.price,
|
||||
currency: pack.currency,
|
||||
);
|
||||
await _testManager.updateGeneratedTests(packModel);
|
||||
ok++;
|
||||
} catch (e) {
|
||||
print(e);
|
||||
|
|
@ -35,9 +53,23 @@ class TestGeneratorTask with Task {
|
|||
}
|
||||
print('Generated tests for $ok packs');
|
||||
print('Deleting old test stats');
|
||||
final count = await isar.writeTxn(() {
|
||||
return isar.testStatisticsModels.filter().testIsNull().deleteAll();
|
||||
});
|
||||
// Delete test statistics where test doesn't exist (test was deleted)
|
||||
final allTests = await _db.testDao.getAllTests();
|
||||
final testIds = allTests.map((t) => t.id).toSet();
|
||||
if (testIds.isEmpty) {
|
||||
// If no tests exist, delete all statistics
|
||||
final count = await (_db.delete(_db.testStatistics)).go();
|
||||
print('deleted $count old test stats');
|
||||
return;
|
||||
}
|
||||
// Get all statistics and filter in memory (more efficient for small datasets)
|
||||
final allStats = await (_db.select(_db.testStatistics)).get();
|
||||
final statsToDelete = allStats.where((stat) => !testIds.contains(stat.testId)).toList();
|
||||
int count = 0;
|
||||
for (final stat in statsToDelete) {
|
||||
await (_db.delete(_db.testStatistics)..where((ts) => ts.id.equals(stat.id))).go();
|
||||
count++;
|
||||
}
|
||||
print('deleted $count old test stats');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,13 +39,14 @@ class AchievementDao extends DatabaseAccessor<AppDatabase> with _$AchievementDao
|
|||
}
|
||||
|
||||
/// Обновить прогресс достижения
|
||||
Future<bool> updateAchievementProgress(int userId, String achievementId, double progress) {
|
||||
return (update(userAchievements)
|
||||
Future<bool> updateAchievementProgress(int userId, String achievementId, double progress) async {
|
||||
final count = await (update(userAchievements)
|
||||
..where((ua) => ua.userId.equals(userId) & ua.achievementId.equals(achievementId))
|
||||
).write(UserAchievementsCompanion(
|
||||
progress: Value(progress),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
));
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/// Получить прогресс по всем достижениям пользователя
|
||||
|
|
@ -58,7 +59,7 @@ class AchievementDao extends DatabaseAccessor<AppDatabase> with _$AchievementDao
|
|||
|
||||
/// Удалить достижение пользователя (для сброса)
|
||||
Future<void> removeAchievement(int userId, String achievementId) {
|
||||
(delete(userAchievements)
|
||||
return (delete(userAchievements)
|
||||
..where((ua) => ua.userId.equals(userId) & ua.achievementId.equals(achievementId))
|
||||
).go();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,49 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
|
|||
return update(db.discountCampaigns).replace(campaign);
|
||||
}
|
||||
|
||||
/// Обновить статус кампании
|
||||
Future<void> updateCampaignStatus(int campaignId, String status) {
|
||||
return (update(db.discountCampaigns)
|
||||
..where((c) => c.id.equals(campaignId))
|
||||
).write(DiscountCampaignsCompanion(
|
||||
status: Value(status),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
/// Получить кампании по статусу
|
||||
Future<List<DiscountCampaign>> getCampaignsByStatus(String status) {
|
||||
return (select(db.discountCampaigns)
|
||||
..where((c) => c.status.equals(status))
|
||||
..where((c) => c.isDeleted.equals(false))
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Получить кампании по статусам (несколько)
|
||||
Future<List<DiscountCampaign>> getCampaignsByStatuses(List<String> statuses) {
|
||||
return (select(db.discountCampaigns)
|
||||
..where((c) => c.status.isIn(statuses))
|
||||
..where((c) => c.isDeleted.equals(false))
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Получить все кампании
|
||||
Future<List<DiscountCampaign>> getAllCampaigns() {
|
||||
return (select(db.discountCampaigns)
|
||||
..where((c) => c.isDeleted.equals(false))
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Удалить кампанию (soft delete)
|
||||
Future<void> deleteCampaign(int campaignId) {
|
||||
return (update(db.discountCampaigns)
|
||||
..where((c) => c.id.equals(campaignId))
|
||||
).write(DiscountCampaignsCompanion(
|
||||
isDeleted: const Value(true),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== Discounts ====================
|
||||
|
||||
/// Получить скидку по ID
|
||||
|
|
@ -95,6 +138,22 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
|
|||
).go();
|
||||
}
|
||||
|
||||
/// Отозвать несколько скидок у пользователя
|
||||
Future<void> revokeDiscountsFromUser(int userId, List<int> discountIds) async {
|
||||
if (discountIds.isEmpty) return;
|
||||
await (delete(db.discountUserDatas)
|
||||
..where((dud) => dud.userId.equals(userId) & dud.discountId.isIn(discountIds))
|
||||
).go();
|
||||
}
|
||||
|
||||
/// Дать пользователю доступ к нескольким скидкам
|
||||
Future<void> grantDiscountsToUser(int userId, List<int> discountIds) async {
|
||||
if (discountIds.isEmpty) return;
|
||||
await Future.wait(
|
||||
discountIds.map((discountId) => grantDiscountToUser(userId, discountId))
|
||||
);
|
||||
}
|
||||
|
||||
/// Проверить, есть ли у пользователя доступ к скидке
|
||||
Future<bool> hasDiscountAccess(int userId, int discountId) async {
|
||||
final query = select(db.discountUserDatas)
|
||||
|
|
@ -103,4 +162,52 @@ class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin
|
|||
final result = await query.getSingleOrNull();
|
||||
return result != null;
|
||||
}
|
||||
|
||||
/// Получить активные кампании с учетом тегов и продуктов
|
||||
Future<List<DiscountCampaign>> getActiveCampaignsForUser({
|
||||
required List<String> userTags,
|
||||
String? productType,
|
||||
String? productId,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
final query = select(db.discountCampaigns)
|
||||
..where((c) => c.status.equals('active'))
|
||||
..where((c) => c.start.isSmallerOrEqualValue(now))
|
||||
..where((c) => c.finish.isBiggerOrEqualValue(now))
|
||||
..where((c) => c.isDeleted.equals(false));
|
||||
|
||||
var campaigns = await query.get();
|
||||
|
||||
// Фильтруем по тегам если они есть
|
||||
if (userTags.isNotEmpty) {
|
||||
campaigns = campaigns.where((campaign) {
|
||||
final campaignTags = campaign.tags.toSet();
|
||||
return campaignTags.isEmpty || campaignTags.intersection(userTags.toSet()).isNotEmpty;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Фильтруем по продукту если указан
|
||||
if (productType != null || productId != null) {
|
||||
// Нужно загрузить скидки для каждой кампании и проверить продукты
|
||||
final filteredCampaigns = <DiscountCampaign>[];
|
||||
for (final campaign in campaigns) {
|
||||
final discounts = await getDiscountsByCampaignId(campaign.id);
|
||||
final hasMatchingProduct = discounts.any((discount) {
|
||||
final products = discount.products ?? [];
|
||||
return products.any((product) {
|
||||
if (product is! Map<String, dynamic>) return false;
|
||||
if (productType != null && product['type'] != productType) return false;
|
||||
if (productId != null && product['id'] != productId) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
if (hasMatchingProduct) {
|
||||
filteredCampaigns.add(campaign);
|
||||
}
|
||||
}
|
||||
campaigns = filteredCampaigns;
|
||||
}
|
||||
|
||||
return campaigns;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,11 +92,26 @@ class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
|
|||
}
|
||||
|
||||
/// Получить все карточки пака
|
||||
Future<List<GameCard>> getPackCards(int packId) {
|
||||
return (select(gameCards)
|
||||
..where((c) => c.packId.equals(packId))
|
||||
..orderBy([(c) => OrderingTerm.asc(c.id)]) // TODO: use proper ordering
|
||||
).get();
|
||||
Future<List<GameCard>> getPackCards(int packId) async {
|
||||
final query = select(gameCards).join([
|
||||
leftOuterJoin(
|
||||
cardPackCards,
|
||||
cardPackCards.cardId.equalsExp(gameCards.id) &
|
||||
cardPackCards.packId.equals(packId),
|
||||
),
|
||||
])..where(gameCards.packId.equals(packId));
|
||||
|
||||
final results = await query.get();
|
||||
|
||||
// Сортируем по order из junction table, если есть, иначе по ID
|
||||
results.sort((a, b) {
|
||||
final orderA = a.read(cardPackCards.order) ?? 999999;
|
||||
final orderB = a.read(cardPackCards.order) ?? 999999;
|
||||
if (orderA != orderB) return orderA.compareTo(orderB);
|
||||
return a.readTable(gameCards).id.compareTo(b.readTable(gameCards).id);
|
||||
});
|
||||
|
||||
return results.map((row) => row.readTable(gameCards)).toList();
|
||||
}
|
||||
|
||||
/// Получить карточки по списку ID
|
||||
|
|
@ -275,20 +290,4 @@ class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
|
|||
Future<VoiceModel?> getVoiceById(int id) {
|
||||
return (select(voiceModels)..where((v) => v.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Подсчитать все паки
|
||||
Future<int> countPacks() async {
|
||||
final countExpr = cardPacks.id.count();
|
||||
final query = selectOnly(cardPacks)..addColumns([countExpr]);
|
||||
|
||||
return await query.map((row) => row.read(countExpr)!).getSingle();
|
||||
}
|
||||
|
||||
/// Подсчитать все карточки
|
||||
Future<int> countCards() async {
|
||||
final countExpr = gameCards.id.count();
|
||||
final query = selectOnly(gameCards)..addColumns([countExpr]);
|
||||
|
||||
return await query.map((row) => row.read(countExpr)!).getSingle();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,34 @@ class PromoCodeDao extends DatabaseAccessor<AppDatabase> with _$PromoCodeDaoMixi
|
|||
return update(db.promoCodesCampaigns).replace(campaign);
|
||||
}
|
||||
|
||||
/// Обновить статус кампании
|
||||
Future<void> updateCampaignStatus(int campaignId, String status) {
|
||||
return (update(db.promoCodesCampaigns)
|
||||
..where((c) => c.id.equals(campaignId))
|
||||
).write(PromoCodesCampaignsCompanion(
|
||||
status: Value(status),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
/// Получить кампании по статусам
|
||||
Future<List<PromoCodesCampaign>> getCampaignsByStatuses(List<String> statuses) {
|
||||
return (select(db.promoCodesCampaigns)
|
||||
..where((c) => c.status.isIn(statuses))
|
||||
..where((c) => c.isDeleted.equals(false))
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Создать несколько промокодов
|
||||
Future<List<int>> createPromoCodes(List<PromoCodesCompanion> promoCodes) async {
|
||||
final ids = <int>[];
|
||||
for (final promoCode in promoCodes) {
|
||||
final id = await createPromoCode(promoCode);
|
||||
ids.add(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ==================== PromoCodes ====================
|
||||
|
||||
/// Получить промокод по коду
|
||||
|
|
|
|||
|
|
@ -86,4 +86,21 @@ class SubscriptionDao extends DatabaseAccessor<AppDatabase> with _$SubscriptionD
|
|||
..orderBy([(us) => OrderingTerm.desc(us.finish)])
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Получить активную подписку пользователя (alias для getActiveSubscription)
|
||||
Future<UserSubscription?> getActiveUserSubscription(int userId) {
|
||||
return getActiveSubscription(userId);
|
||||
}
|
||||
|
||||
/// Отменить подписку пользователя (установить finish на текущее время)
|
||||
Future<void> cancelUserSubscription(int userId) async {
|
||||
final subscription = await getActiveSubscription(userId);
|
||||
if (subscription != null) {
|
||||
await update(userSubscriptions).replace(
|
||||
subscription.copyWith(
|
||||
finish: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,15 +47,16 @@ class TaskDao extends DatabaseAccessor<AppDatabase> with _$TaskDaoMixin {
|
|||
return (select(db.userTasks)..where((ut) => ut.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить все задачи пользователей (для системных задач)
|
||||
Future<List<UserTask>> getAllUserTasks() {
|
||||
return select(db.userTasks).get();
|
||||
}
|
||||
|
||||
/// Получить задачи пользователя
|
||||
/// Note: UserTasks table doesn't have userId column directly
|
||||
/// This method needs to be implemented based on actual schema
|
||||
Future<List<UserTask>> getUserTasks(int userId, {
|
||||
String? status,
|
||||
bool activeOnly = false,
|
||||
}) {
|
||||
// TODO: Implement based on actual UserTasks schema
|
||||
// For now, return all tasks
|
||||
final query = select(db.userTasks);
|
||||
|
||||
if (status != null) {
|
||||
|
|
@ -91,6 +92,18 @@ class TaskDao extends DatabaseAccessor<AppDatabase> with _$TaskDaoMixin {
|
|||
updatedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
/// Подсчитать задачи пользователя
|
||||
Future<int> countUserTasks(int userId, {String? status}) async {
|
||||
final countExpr = db.userTasks.id.count();
|
||||
final query = selectOnly(db.userTasks)..addColumns([countExpr]);
|
||||
|
||||
if (status != null) {
|
||||
query.where(db.userTasks.status.equals(status));
|
||||
}
|
||||
|
||||
return await query.map((row) => row.read(countExpr)!).getSingle();
|
||||
}
|
||||
|
||||
// ==================== UserTaskProgresses ====================
|
||||
|
||||
|
|
|
|||
|
|
@ -113,10 +113,19 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
|
|||
|
||||
/// Получить пользователей с активной подпиской
|
||||
Stream<List<User>> watchUsersWithActiveSubscription() {
|
||||
// TODO: implement with join to UserSubscriptions when SubscriptionDao is ready
|
||||
return (select(users)
|
||||
..where((u) => u.isDeleted.equals(false))
|
||||
).watch();
|
||||
final now = DateTime.now();
|
||||
final query = select(users).join([
|
||||
innerJoin(
|
||||
db.userSubscriptions,
|
||||
db.userSubscriptions.userId.equalsExp(users.id) &
|
||||
db.userSubscriptions.start.isSmallerThanValue(now) &
|
||||
db.userSubscriptions.finish.isBiggerThanValue(now),
|
||||
),
|
||||
])..where(users.isDeleted.equals(false));
|
||||
|
||||
return query.watch().map((rows) =>
|
||||
rows.map((row) => row.readTable(users)).toList()
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== UserData ====================
|
||||
|
|
@ -163,6 +172,15 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
|
|||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить токен по externalUserId
|
||||
Future<Token?> getTokenByExternalUserId(String externalUserId) {
|
||||
return (select(tokens)
|
||||
..where((t) => t.externalUserId.equals(externalUserId))
|
||||
..orderBy([(t) => OrderingTerm.desc(t.created)])
|
||||
..limit(1)
|
||||
).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить токен пользователя
|
||||
Future<Token?> getTokenByUserId(int userId) {
|
||||
return (select(tokens)
|
||||
|
|
|
|||
133
mnemo_cards_backend/lib/discounts/discount_drift_extension.dart
Normal file
133
mnemo_cards_backend/lib/discounts/discount_drift_extension.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +1,63 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/discounts/discount_campaign_ext.dart';
|
||||
import 'package:mnemo_cards_backend/database/daos/discount_dao.dart';
|
||||
import 'package:mnemo_cards_backend/database/daos/user_dao.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/discounts/discount_drift_extension.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
||||
import '../main.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart' hide DateTimeExt;
|
||||
import 'package:mnemo_cards_common/src/utils/utils.dart';
|
||||
|
||||
@lazySingleton
|
||||
class DiscountsManager {
|
||||
final AppDatabase _db;
|
||||
final DiscountDao _discountDao;
|
||||
final UserDao _userDao;
|
||||
|
||||
DiscountsManager(this._db)
|
||||
: _discountDao = _db.discountDao,
|
||||
_userDao = _db.userDao;
|
||||
|
||||
Future<void> applyDiscount({
|
||||
required Iterable<DiscountModel> discounts,
|
||||
required UserModel user,
|
||||
}) async {
|
||||
await isar.writeTxn(() async {
|
||||
await user.userData.load();
|
||||
final userData = user.userData.value!;
|
||||
userData.activeDiscounts.addAll(discounts);
|
||||
await user.userData.save();
|
||||
});
|
||||
if (user.id == null) return;
|
||||
|
||||
// Получаем ID скидок
|
||||
final discountIds = discounts
|
||||
.where((d) => d.id != null)
|
||||
.map((d) => d.id as int)
|
||||
.toList();
|
||||
|
||||
if (discountIds.isEmpty) return;
|
||||
|
||||
// Добавляем скидки пользователю через junction таблицу
|
||||
await _discountDao.grantDiscountsToUser(user.id!, discountIds);
|
||||
}
|
||||
|
||||
Future<void> changeCampaignStatus(DiscountCampaignModel campaign) async {
|
||||
Future<void> changeCampaignStatus(DiscountCampaign campaign) async {
|
||||
final now = DateTime.now();
|
||||
var target = campaign.status;
|
||||
if (campaign.status == DiscountCampaignModelStatus.created &&
|
||||
String? targetStatus;
|
||||
|
||||
if (campaign.status == 'created' &&
|
||||
now.isBetween(campaign.start, campaign.finish)) {
|
||||
target = DiscountCampaignModelStatus.active;
|
||||
} else if (campaign.status == DiscountCampaignModelStatus.active) {
|
||||
targetStatus = 'active';
|
||||
} else if (campaign.status == 'active') {
|
||||
if (now.isBefore(campaign.start)) {
|
||||
target = DiscountCampaignModelStatus.created;
|
||||
targetStatus = 'created';
|
||||
} else if (now.isAfter(campaign.finish)) {
|
||||
target = DiscountCampaignModelStatus.expired;
|
||||
targetStatus = 'expired';
|
||||
}
|
||||
}
|
||||
if (target != campaign.status) {
|
||||
await isar.writeTxn(() => isar.discountCampaignModels.put(
|
||||
campaign.copyWith(status: target),
|
||||
));
|
||||
|
||||
if (targetStatus != null && targetStatus != campaign.status) {
|
||||
await _discountDao.updateCampaignStatus(campaign.id, targetStatus);
|
||||
}
|
||||
}
|
||||
|
||||
Future<DiscountError?> applyCampaign(DiscountCampaignModel campaign) async {
|
||||
Future<DiscountError?> applyCampaign(DiscountCampaign campaign) async {
|
||||
final now = DateTime.now();
|
||||
if (!now.isBetween(campaign.start, campaign.finish)) {
|
||||
if (now.isBefore(campaign.start)) {
|
||||
|
|
@ -50,109 +65,101 @@ class DiscountsManager {
|
|||
}
|
||||
return DiscountError(DiscountErrorType.expired);
|
||||
}
|
||||
// final usersData = await isar.txn(
|
||||
// () => isar.userDataModels
|
||||
// .filter()
|
||||
// .optional(
|
||||
// campaign.tags.isNotEmpty,
|
||||
// (q) => q.anyOf(
|
||||
// campaign.tags,
|
||||
// (q, tag) => q.tagsElementContains(tag),
|
||||
// ),
|
||||
// )
|
||||
// .findAll(),
|
||||
// );
|
||||
// TODO: Implement applying campaign to users based on tags
|
||||
// This would require querying UserDatas with matching tags
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> removeDiscount({
|
||||
required DiscountCampaignModel campaign,
|
||||
required DiscountCampaign campaign,
|
||||
required UserModel user,
|
||||
}) async {
|
||||
await isar.writeTxn(() async {
|
||||
await user.userData.load();
|
||||
final userData = user.userData.value!;
|
||||
userData.activeDiscounts.removeAll(campaign.discounts);
|
||||
await isar.userDataModels.put(userData);
|
||||
});
|
||||
if (user.id == null) return;
|
||||
|
||||
// Получаем скидки кампании
|
||||
final discounts = await _discountDao.getDiscountsByCampaignId(campaign.id);
|
||||
final discountIds = discounts.map((d) => d.id).toList();
|
||||
|
||||
if (discountIds.isEmpty) return;
|
||||
|
||||
// Удаляем скидки пользователя
|
||||
await _discountDao.revokeDiscountsFromUser(user.id!, discountIds);
|
||||
}
|
||||
|
||||
Future<double> getProductDiscount(
|
||||
MnemoCardsProductModel model,
|
||||
UserDataModel userData,
|
||||
UserData userData,
|
||||
) async {
|
||||
double maxDiscount = 0;
|
||||
for (final discount in userData.activeDiscounts) {
|
||||
if (discount.discountPercent > maxDiscount &&
|
||||
discount.products.any((p) => p.equalsProduct(model))) {
|
||||
maxDiscount = discount.discountPercent;
|
||||
|
||||
// Получаем активные скидки пользователя из junction таблицы
|
||||
final userDiscounts = await _discountDao.getUserDiscounts(userData.userId);
|
||||
for (final discount in userDiscounts) {
|
||||
final discountDto = discount.toDto();
|
||||
if (discountDto.discountPercent > maxDiscount &&
|
||||
discountDto.products.any((p) => model.equalsDto(p))) {
|
||||
maxDiscount = discountDto.discountPercent;
|
||||
}
|
||||
}
|
||||
|
||||
final activeCampaigns =
|
||||
await discountCampaignsForUser(userData, product: model);
|
||||
// Получаем активные кампании
|
||||
final user = await _userDao.getUserById(userData.userId);
|
||||
if (user == null) return maxDiscount;
|
||||
|
||||
final userDataModel = await _userDao.getUserData(userData.userId);
|
||||
final userTags = userDataModel?.tags ?? [];
|
||||
|
||||
final activeCampaigns = await _discountDao.getActiveCampaignsForUser(
|
||||
userTags: userTags,
|
||||
productType: model.type.name,
|
||||
productId: model.id.toString(),
|
||||
);
|
||||
|
||||
for (final campaign in activeCampaigns) {
|
||||
for (final discount in campaign.discounts) {
|
||||
if (discount.discountPercent > maxDiscount &&
|
||||
discount.products.any((p) => p.equalsProduct(model))) {
|
||||
maxDiscount = discount.discountPercent;
|
||||
final discounts = await _discountDao.getDiscountsByCampaignId(campaign.id);
|
||||
for (final discount in discounts) {
|
||||
final discountDto = discount.toDto();
|
||||
if (discountDto.discountPercent > maxDiscount &&
|
||||
discountDto.products.any((p) => model.equalsDto(p))) {
|
||||
maxDiscount = discountDto.discountPercent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxDiscount;
|
||||
}
|
||||
|
||||
Future<List<DiscountCampaignModel>> discountCampaignsForUser(
|
||||
UserDataModel userModel, {
|
||||
Future<List<DiscountCampaign>> discountCampaignsForUser(
|
||||
UserData userData, {
|
||||
MnemoCardsProductModel? product,
|
||||
}) async {
|
||||
final activeCampaigns =
|
||||
await isar.txn(() async => isar.discountCampaignModels
|
||||
.filter()
|
||||
.statusEqualTo(DiscountCampaignModelStatus.active)
|
||||
.optional(userModel.tags.isEmpty, (q) => q.tagsIsEmpty())
|
||||
.optional(
|
||||
product != null,
|
||||
(q) => q.discounts(
|
||||
(d) => d.productsElement(
|
||||
(p) => p.typeEqualTo(product!.type).productIdEqualTo(
|
||||
product.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.findAll());
|
||||
if (userModel.tags.isEmpty) {
|
||||
return activeCampaigns;
|
||||
} else {
|
||||
// todo check tags in query
|
||||
final userTags = userModel.tags.toSet();
|
||||
return activeCampaigns
|
||||
.where((campaign) => campaign.tags.hasIntersection(userTags))
|
||||
.toList();
|
||||
}
|
||||
final userTags = userData.tags;
|
||||
return await _discountDao.getActiveCampaignsForUser(
|
||||
userTags: userTags,
|
||||
productType: product?.type.name,
|
||||
productId: product?.id.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<DiscountCampaignDto>> discountCampaigns() async {
|
||||
final models = await isar
|
||||
.txn(() async => isar.discountCampaignModels.where().findAll());
|
||||
final dtos = await Future.wait(models.map((m) async => await m.toDto()));
|
||||
final campaigns = await _discountDao.getAllCampaigns();
|
||||
final dtos = await Future.wait(
|
||||
campaigns.map((c) async => await c.toDto(_discountDao)),
|
||||
);
|
||||
return dtos;
|
||||
}
|
||||
|
||||
Future<String?> deleteDiscountCampaign(String stringId) async {
|
||||
try {
|
||||
final id = int.parse(stringId);
|
||||
isar.writeTxn(() async {
|
||||
final model = await isar.discountCampaignModels.get(id);
|
||||
if (model == null) {
|
||||
return 'Campaign $id not found';
|
||||
}
|
||||
if (model.status != DiscountCampaignModelStatus.disabled) {
|
||||
return 'Disable campaign before deleting';
|
||||
}
|
||||
isar.discountCampaignModels.delete(id);
|
||||
});
|
||||
final campaign = await _discountDao.getCampaignById(id);
|
||||
if (campaign == null) {
|
||||
return 'Campaign $id not found';
|
||||
}
|
||||
if (campaign.status != 'disabled') {
|
||||
return 'Disable campaign before deleting';
|
||||
}
|
||||
await _discountDao.deleteCampaign(id);
|
||||
return null;
|
||||
} catch (e, s) {
|
||||
log('Error when deleting discount campaign', error: e, stackTrace: s);
|
||||
|
|
@ -162,32 +169,28 @@ class DiscountsManager {
|
|||
|
||||
Future<bool> addDiscount(DiscountCampaignDto dto) async {
|
||||
try {
|
||||
final model = DiscountCampaignModel(
|
||||
id: dto.id,
|
||||
start: dto.start,
|
||||
finish: dto.finish,
|
||||
status: dto.status.toModel(),
|
||||
name: dto.name,
|
||||
tags: dto.tags,
|
||||
);
|
||||
final discounts = dto.discounts.map((d) => d.toModel()).toList();
|
||||
await isar.writeTxn(() async {
|
||||
await isar.discountModels.putAll(discounts);
|
||||
await isar.discountCampaignModels.put(model);
|
||||
await model.discounts.reset();
|
||||
model.discounts.addAll(discounts);
|
||||
await model.discounts.save();
|
||||
});
|
||||
return await _db.transaction(() async {
|
||||
// Создаем кампанию
|
||||
final campaignCompanion = dto.toCompanion();
|
||||
final campaignId = await _discountDao.createCampaign(campaignCompanion);
|
||||
|
||||
// Создаем скидки
|
||||
final discountCompanions = dto.discounts
|
||||
.map((discountDto) => discountDto.toCompanion(campaignId))
|
||||
.toList();
|
||||
|
||||
for (final discountCompanion in discountCompanions) {
|
||||
await _discountDao.createDiscount(discountCompanion);
|
||||
}
|
||||
|
||||
return true;
|
||||
return true;
|
||||
});
|
||||
} catch (e, s) {
|
||||
print('error when adding campaign');
|
||||
log('error when adding campaign', error: e, stackTrace: s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const DiscountsManager();
|
||||
}
|
||||
|
||||
class DiscountError {
|
||||
|
|
|
|||
|
|
@ -76,19 +76,19 @@ void main() async {
|
|||
);
|
||||
|
||||
// For rustore payments
|
||||
final rustoreEnabled = false;
|
||||
if (rustoreEnabled) {
|
||||
final pythonInit = await Process.run(
|
||||
workingDirectory: WORK_DIR,
|
||||
'./prepare_python.sh',
|
||||
[],
|
||||
runInShell: true,
|
||||
);
|
||||
print(pythonInit.stdout);
|
||||
print(pythonInit.stderr);
|
||||
} else {
|
||||
log('\n\n*******\nRUSTORE IS DISABLED\n******\n\n', level: 1000);
|
||||
}
|
||||
// final rustoreEnabled = false;
|
||||
// if (rustoreEnabled) {
|
||||
// final pythonInit = await Process.run(
|
||||
// workingDirectory: WORK_DIR,
|
||||
// './prepare_python.sh',
|
||||
// [],
|
||||
// runInShell: true,
|
||||
// );
|
||||
// print(pythonInit.stdout);
|
||||
// print(pythonInit.stderr);
|
||||
// } else {
|
||||
// log('\n\n*******\nRUSTORE IS DISABLED\n******\n\n', level: 1000);
|
||||
// }
|
||||
|
||||
// Инициализация PostgreSQL
|
||||
try {
|
||||
|
|
@ -109,15 +109,14 @@ void main() async {
|
|||
// ignore: unawaited_futures
|
||||
CronManager([
|
||||
DeleteOldArchives(),
|
||||
CheckAdminsTask(),
|
||||
// TestGeneratorTask(getIt.get<TestManager>()), // TODO: enable after TestManager migration
|
||||
CheckAdminsTask(database),
|
||||
getIt.get<CheckPaymentTask>(),
|
||||
AddFreePacks(getIt.get<FreePacksDistributor>()),
|
||||
AddFreePacks(getIt.get<FreePacksDistributor>(), database),
|
||||
Backup(backupDir),
|
||||
GeneratePromocodes(),
|
||||
DiscountCampaignTask(getIt.get<DiscountsManager>()),
|
||||
GeneratePromocodes(database),
|
||||
DiscountCampaignTask(getIt.get<DiscountsManager>(), database),
|
||||
UpdateOnlineUsersTask(getIt.get<UserManager>()),
|
||||
TasksSeederTask(),
|
||||
TasksSeederTask(database),
|
||||
]).init();
|
||||
|
||||
print('✅ Backend started successfully!');
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart' hide VoiceModel;
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
|
@ -5,28 +6,24 @@ import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
|||
/// Extension для конвертации CardPack (Drift) в DTO
|
||||
extension CardPackToDto on CardPack {
|
||||
Future<CardPackPreviewDto> toPreviewDto(UserModel? user) async {
|
||||
// TODO: Check if user has access to this pack
|
||||
final hasAccess = user != null; // Simplified check
|
||||
final hasAccess = user != null && (user.purchases.contains(id.toString()) || user.admin);
|
||||
|
||||
return CardPackPreviewDto(
|
||||
id: id.toString(),
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
description: description,
|
||||
color: color,
|
||||
cover: cover,
|
||||
size: size,
|
||||
cards: size,
|
||||
tests: null, // TODO: Get tests count if needed
|
||||
price: price,
|
||||
currency: currency,
|
||||
imageBase64: cover,
|
||||
color: color,
|
||||
isAvailable: hasAccess,
|
||||
order: order,
|
||||
tip: null,
|
||||
version: version,
|
||||
);
|
||||
}
|
||||
|
||||
Future<CardPackDto> toDto(List<GameCard> cards, List<VoiceModel> voices, UserModel? user) async {
|
||||
// TODO: Check if user has access to this pack
|
||||
final hasAccess = user != null; // Simplified check
|
||||
|
||||
final cardsDto = await Future.wait(
|
||||
cards.map((card) => card.toDto(voices.where((v) => v.cardId == card.id).toList()))
|
||||
);
|
||||
|
|
@ -35,15 +32,9 @@ extension CardPackToDto on CardPack {
|
|||
id: id.toString(),
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
description: description,
|
||||
color: color,
|
||||
cover: cover,
|
||||
size: size,
|
||||
price: price,
|
||||
currency: currency,
|
||||
isAvailable: hasAccess,
|
||||
cards: cardsDto,
|
||||
order: order,
|
||||
color: color,
|
||||
version: version ?? '1.0.0',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -51,23 +42,16 @@ extension CardPackToDto on CardPack {
|
|||
/// Extension для конвертации GameCard (Drift) в DTO
|
||||
extension GameCardToDto on GameCard {
|
||||
Future<GameCardDto> toDto(List<VoiceModel> voices) async {
|
||||
final voicesDto = voices.map((v) => VoiceDto(
|
||||
id: v.id.toString(),
|
||||
path: v.voiceUrl,
|
||||
speaker: '', // TODO: add speaker field
|
||||
)).toList();
|
||||
|
||||
return GameCardDto(
|
||||
id: id.toString(),
|
||||
id: id,
|
||||
original: original,
|
||||
translation: translation,
|
||||
mnemo: mnemo,
|
||||
mnemo: mnemo ?? '',
|
||||
image: image,
|
||||
imageBack: imageBack,
|
||||
transcription: transcription,
|
||||
transcription: transcription ?? '',
|
||||
transcriptionMnemo: transcriptionMnemo,
|
||||
back: '', // TODO: add back field
|
||||
voices: voicesDto,
|
||||
imageBack: imageBack,
|
||||
back: back,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -77,20 +61,20 @@ extension CardPackFromDto on CardPackDto {
|
|||
CardPacksCompanion toCompanion() {
|
||||
return CardPacksCompanion.insert(
|
||||
title: title,
|
||||
subtitle: subtitle ?? '',
|
||||
description: description,
|
||||
subtitle: subtitle,
|
||||
description: const drift.Value.absent(),
|
||||
color: drift.Value(color),
|
||||
cover: drift.Value(cover),
|
||||
cover: const drift.Value.absent(),
|
||||
size: cards.length,
|
||||
version: drift.Value('1.0.0'), // TODO: version handling
|
||||
order: order ?? 0,
|
||||
enabled: true,
|
||||
cardsOrder: drift.Value(cards.map((c) => int.parse(c.id)).toList()),
|
||||
googlePlayId: drift.Value(''), // TODO: store IDs
|
||||
rustoreId: drift.Value(''),
|
||||
appStoreId: drift.Value(''),
|
||||
price: drift.Value(price),
|
||||
currency: currency ?? 'RUB',
|
||||
version: drift.Value(version),
|
||||
order: const drift.Value(0),
|
||||
enabled: const drift.Value(true),
|
||||
cardsOrder: drift.Value(cards.map((c) => c.id).toList()),
|
||||
googlePlayId: const drift.Value.absent(),
|
||||
rustoreId: const drift.Value.absent(),
|
||||
appStoreId: const drift.Value.absent(),
|
||||
price: const drift.Value.absent(),
|
||||
currency: const drift.Value('RUB'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -100,14 +84,14 @@ extension GameCardFromDto on GameCardDto {
|
|||
GameCardsCompanion toCompanion(int packId) {
|
||||
return GameCardsCompanion.insert(
|
||||
packId: packId,
|
||||
original: original,
|
||||
translation: translation,
|
||||
original: original ?? '',
|
||||
translation: translation ?? '',
|
||||
mnemo: drift.Value(mnemo),
|
||||
image: drift.Value(image),
|
||||
image: image ?? '',
|
||||
imageBack: drift.Value(imageBack),
|
||||
transcription: drift.Value(transcription),
|
||||
transcriptionMnemo: drift.Value(transcriptionMnemo),
|
||||
back: drift.Value(''), // TODO: back field
|
||||
back: drift.Value(back),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,19 @@
|
|||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
|
||||
import '../main.dart';
|
||||
|
||||
@lazySingleton
|
||||
class FreePacksDistributor {
|
||||
Future<List<CardPackModel>> getFreePacks() async {
|
||||
final freePacks = await isar.txn(
|
||||
() async => await isar.cardPackModels
|
||||
.filter()
|
||||
.priceIsEmpty()
|
||||
.or()
|
||||
.priceIsNull()
|
||||
.enabledEqualTo(true)
|
||||
.findAll(),
|
||||
);
|
||||
return freePacks;
|
||||
final AppDatabase _db;
|
||||
|
||||
FreePacksDistributor(this._db);
|
||||
|
||||
Future<List<CardPack>> getFreePacks() async {
|
||||
final allPacks = await _db.packDao.getAllPacks(enabledOnly: true);
|
||||
// Фильтруем паки с нулевой ценой или без цены
|
||||
return allPacks.where((pack) =>
|
||||
pack.price == null || pack.price == 0
|
||||
).toList();
|
||||
}
|
||||
|
||||
Future<void> giveFreePacksToUser(UserModel user) async {
|
||||
|
|
@ -27,11 +24,16 @@ class FreePacksDistributor {
|
|||
|
||||
Future<void> givePacksToUser(
|
||||
UserModel user,
|
||||
List<CardPackModel> packs,
|
||||
) async =>
|
||||
isar.writeTxn(
|
||||
() => (user.packs..addAll(packs)).save(),
|
||||
List<CardPack> packs,
|
||||
) async {
|
||||
if (user.id == null) return;
|
||||
|
||||
for (final pack in packs) {
|
||||
await _db.userDao.grantPackAccess(
|
||||
userId: user.id!,
|
||||
packId: pack.id,
|
||||
grantType: 'free',
|
||||
);
|
||||
|
||||
const FreePacksDistributor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import 'package:injectable/injectable.dart';
|
||||
import 'package:mnemo_cards_backend/api/ads/ads_manager.dart';
|
||||
import 'package:mnemo_cards_backend/extensions.dart';
|
||||
import 'package:mnemo_cards_backend/promo_codes/promo_codes_manager.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_backend/packs/card_model_extension.dart';
|
||||
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
|
||||
import 'package:mnemo_cards_backend/packs/pack_manager_extensions.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
||||
import 'products_price_resolver.dart';
|
||||
|
|
@ -47,7 +46,9 @@ class PackDtoConverter {
|
|||
cards: model.cards.length,
|
||||
tests: model.tests.length,
|
||||
price: price,
|
||||
imageBase64: await model.cover?.smallBase64Image,
|
||||
imageBase64: model.cover != null
|
||||
? await model.cover!.smallBase64Image
|
||||
: null,
|
||||
color: model.color,
|
||||
isAvailable: available,
|
||||
// trail: 'asset:icons/gift.png',
|
||||
|
|
@ -95,7 +96,9 @@ class PackDtoConverter {
|
|||
.toDtosList(model.cardsOrder)
|
||||
.map(
|
||||
(dto) async => dto.copyWith(
|
||||
image: await dto.image?.smallBase64Image,
|
||||
image: dto.image != null
|
||||
? await dto.image!.smallBase64Image
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -136,24 +139,7 @@ class PackDtoConverter {
|
|||
).uri.toString(),
|
||||
),
|
||||
],
|
||||
if (model.googlePlayId != null && false)
|
||||
ButtonItem(
|
||||
height: 110,
|
||||
children: [
|
||||
TextItem(
|
||||
title: 'Оплатить через',
|
||||
subtitle: 'Google Pay',
|
||||
)
|
||||
],
|
||||
deeplink: PurchaseInitDeeplink(
|
||||
id: model.id.toString(),
|
||||
productType: MnemoCardsProductType.pack,
|
||||
paymentId: model.googlePlayId!,
|
||||
paymentSystem: PaymentSystem.google,
|
||||
).uri.toString(),
|
||||
)
|
||||
else
|
||||
SpacerItem(height: 20, flex: 0)
|
||||
SpacerItem(height: 20, flex: 0)
|
||||
],
|
||||
color: model.color,
|
||||
version: model.version,
|
||||
|
|
@ -176,7 +162,7 @@ class PackDtoConverter {
|
|||
subtitle: model.subtitle,
|
||||
color: model.color,
|
||||
version: model.version,
|
||||
cover: await model.cover?.base64Image,
|
||||
cover: model.cover != null ? await model.cover!.base64Image : null,
|
||||
size: model.size,
|
||||
googlePlayId: model.googlePlayId,
|
||||
rustoreId: model.rustoreId,
|
||||
|
|
|
|||
|
|
@ -53,13 +53,11 @@ class PackManager {
|
|||
}
|
||||
|
||||
Future<VoiceModel?> getVoice(int id) async {
|
||||
// TODO: Implement in PackDao
|
||||
return null;
|
||||
return await _db.packDao.getVoiceById(id);
|
||||
}
|
||||
|
||||
Future<List<VoiceModel>> getVoices(int cardId) async {
|
||||
// TODO: Implement in PackDao
|
||||
return [];
|
||||
return await _db.packDao.getCardVoices(cardId);
|
||||
}
|
||||
|
||||
Future<CardPackDto> getPackDto(int id, UserModel? userModel) async {
|
||||
|
|
@ -84,9 +82,8 @@ class PackManager {
|
|||
throw StateError('Pack not found');
|
||||
}
|
||||
|
||||
final cards = await getCards(packId);
|
||||
// Take first 6 cards for preview (TODO: add previewCardsOrder field to CardPack)
|
||||
final previewCards = cards.take(6).toList();
|
||||
final previewCards = await _db.packDao.getPreviewCards(packId);
|
||||
final cards = previewCards.isNotEmpty ? previewCards : (await getCards(packId)).take(6).toList();
|
||||
|
||||
final images = <String>[];
|
||||
for (final card in previewCards) {
|
||||
|
|
|
|||
|
|
@ -36,8 +36,6 @@ extension CoverStringExt on String? {
|
|||
}
|
||||
|
||||
Uint8List _getById(String id, String type, _ImageSize? size) {
|
||||
// TODO: Implement image resizing
|
||||
// For now, just return the original file
|
||||
try {
|
||||
return File('${PackManagerUtils.assetsDirectory.path}/$type/$id')
|
||||
.readAsBytesSync();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import 'package:injectable/injectable.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
|
||||
@LazySingleton()
|
||||
class ProductsPriceResolver {
|
||||
final DiscountsManager _discountsManager;
|
||||
final AppDatabase _db;
|
||||
|
||||
const ProductsPriceResolver(this._discountsManager);
|
||||
ProductsPriceResolver(this._discountsManager, this._db);
|
||||
|
||||
Future<String> _userProductPrice(
|
||||
UserModel userModel,
|
||||
|
|
@ -17,17 +19,18 @@ class ProductsPriceResolver {
|
|||
if (priceDouble == null) {
|
||||
return price;
|
||||
}
|
||||
userModel.userData.load();
|
||||
final userData = userModel.userData.value;
|
||||
if (userData != null) {
|
||||
final maxDiscount = await _discountsManager.getProductDiscount(
|
||||
product,
|
||||
userData,
|
||||
);
|
||||
if (maxDiscount > 0) {
|
||||
price = (priceDouble * (1 - maxDiscount / 100.0).clamp(0, 1.0))
|
||||
.round()
|
||||
.toString();
|
||||
if (userModel.id != null) {
|
||||
final userData = await _db.userDao.getUserData(userModel.id!);
|
||||
if (userData != null) {
|
||||
final maxDiscount = await _discountsManager.getProductDiscount(
|
||||
product,
|
||||
userData,
|
||||
);
|
||||
if (maxDiscount > 0) {
|
||||
price = (priceDouble * (1 - maxDiscount / 100.0).clamp(0, 1.0))
|
||||
.round()
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
return '$price';
|
||||
|
|
|
|||
|
|
@ -35,11 +35,15 @@ class PromoCodesManager {
|
|||
promoCodes = codes.map((code) => code.code).toList();
|
||||
}
|
||||
|
||||
final products = (campaign.products ?? [])
|
||||
.map((p) => MnemoCardsProductDto.fromJson(p as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
campaignDtos.add(PromoCodesCampaignDto(
|
||||
id: campaign.id,
|
||||
name: campaign.name,
|
||||
template: campaign.template,
|
||||
products: [], // TODO: convert from JSON
|
||||
products: products,
|
||||
activationsPerCode: campaign.activationsPerCode,
|
||||
activationsPerUser: campaign.activationsPerUser,
|
||||
generationSize: campaign.generationSize,
|
||||
|
|
@ -64,11 +68,15 @@ class PromoCodesManager {
|
|||
final codes = await _db.promoCodeDao.getPromoCodesByCampaignId(campaignId);
|
||||
final promoCodes = codes.map((code) => code.code).toList();
|
||||
|
||||
final products = (campaign.products ?? [])
|
||||
.map((p) => MnemoCardsProductDto.fromJson(p as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
return PromoCodesCampaignDto(
|
||||
id: campaign.id,
|
||||
name: campaign.name,
|
||||
template: campaign.template,
|
||||
products: [], // TODO: convert from JSON
|
||||
products: products,
|
||||
activationsPerCode: campaign.activationsPerCode,
|
||||
activationsPerUser: campaign.activationsPerUser,
|
||||
generationSize: campaign.generationSize,
|
||||
|
|
@ -81,17 +89,19 @@ class PromoCodesManager {
|
|||
}
|
||||
|
||||
Future<void> createPromoCodeCampaign(PromoCodesCampaignDto dto) async {
|
||||
final productsJson = dto.products.map((p) => p.toJson()).toList();
|
||||
|
||||
final companion = PromoCodesCampaignsCompanion.insert(
|
||||
template: dto.template,
|
||||
name: drift.Value(dto.name),
|
||||
products: [], // TODO: convert from dto.products
|
||||
products: drift.Value(productsJson),
|
||||
activationsPerCode: dto.activationsPerCode,
|
||||
activationsPerUser: dto.activationsPerUser,
|
||||
generationSize: dto.generationSize,
|
||||
start: dto.start,
|
||||
finish: dto.finish,
|
||||
status: dto.status.name,
|
||||
tags: dto.tags,
|
||||
tags: drift.Value(dto.tags),
|
||||
);
|
||||
|
||||
await _db.promoCodeDao.createCampaign(companion);
|
||||
|
|
@ -108,10 +118,12 @@ class PromoCodesManager {
|
|||
throw StateError('Campaign not found: $id');
|
||||
}
|
||||
|
||||
final productsJson = dto.products.map((p) => p.toJson()).toList();
|
||||
|
||||
final updated = existing.copyWith(
|
||||
template: dto.template,
|
||||
name: dto.name,
|
||||
products: [], // TODO: convert from dto.products
|
||||
name: drift.Value(dto.name),
|
||||
products: drift.Value(productsJson as List<dynamic>?),
|
||||
activationsPerCode: dto.activationsPerCode,
|
||||
activationsPerUser: dto.activationsPerUser,
|
||||
generationSize: dto.generationSize,
|
||||
|
|
@ -176,8 +188,6 @@ class PromoCodesManager {
|
|||
return {'valid': false, 'message': 'Promo code exhausted'};
|
||||
}
|
||||
|
||||
// TODO: Check user-specific limits
|
||||
|
||||
return {
|
||||
'valid': true,
|
||||
'campaign': {
|
||||
|
|
@ -190,7 +200,7 @@ class PromoCodesManager {
|
|||
}
|
||||
|
||||
Future<dynamic> applyPromoCode(dynamic dto, dynamic user) async {
|
||||
final code = dto['code']?.toString()?.toUpperCase();
|
||||
final code = dto['code']?.toString().toUpperCase();
|
||||
if (code == null) {
|
||||
throw ArgumentError('Promo code is required');
|
||||
}
|
||||
|
|
@ -203,17 +213,84 @@ class PromoCodesManager {
|
|||
final promoCode = await _db.promoCodeDao.getPromoCodeByCode(code);
|
||||
if (promoCode == null) return null;
|
||||
|
||||
// Get user ID
|
||||
final userId = user is int ? user : int.tryParse(user?.toString() ?? '');
|
||||
if (userId == null) {
|
||||
throw ArgumentError('Invalid user');
|
||||
}
|
||||
|
||||
// Increment activations
|
||||
await _db.promoCodeDao.incrementActivations(promoCode.id);
|
||||
|
||||
// TODO: Apply the promo code benefits to user
|
||||
// Apply the promo code benefits to user
|
||||
final campaign = validation['campaign'] as Map<String, dynamic>;
|
||||
final products = campaign['products'] as List<dynamic>;
|
||||
|
||||
if (products.isNotEmpty) {
|
||||
for (final productJson in products) {
|
||||
final product = MnemoCardsProductDto.fromJson(productJson as Map<String, dynamic>);
|
||||
await _paymentManager.grantProductToUser(userId, product);
|
||||
}
|
||||
}
|
||||
|
||||
return {'success': true, 'code': code};
|
||||
}
|
||||
|
||||
Future<bool> launchPromoCodesCampaign(dynamic campaign) async {
|
||||
// TODO: Generate promo codes for campaign
|
||||
// This involves generating codes based on template and generationSize
|
||||
return false;
|
||||
Future<bool> launchPromoCodesCampaign(int campaignId) async {
|
||||
final campaign = await _db.promoCodeDao.getCampaignById(campaignId);
|
||||
if (campaign == null) return false;
|
||||
|
||||
if (campaign.status != 'ready') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generate promo codes based on template and generationSize
|
||||
final codes = <String>[];
|
||||
for (int i = 0; i < campaign.generationSize; i++) {
|
||||
final code = _generatePromoCode(campaign.template, i);
|
||||
codes.add(code);
|
||||
}
|
||||
|
||||
// Insert codes into database
|
||||
await _db.transaction(() async {
|
||||
for (final code in codes) {
|
||||
await _db.promoCodeDao.createPromoCode(
|
||||
PromoCodesCompanion.insert(
|
||||
campaignId: campaignId,
|
||||
code: code,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Update campaign status to active
|
||||
await _db.promoCodeDao.updateCampaign(
|
||||
campaign.copyWith(
|
||||
status: 'active',
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
String _generatePromoCode(String template, int index) {
|
||||
// Simple implementation: replace {random} with random characters
|
||||
// and {index} with the current index
|
||||
final random = _generateRandomString(8);
|
||||
return template
|
||||
.replaceAll('{random}', random)
|
||||
.replaceAll('{index}', index.toString().padLeft(4, '0'))
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
String _generateRandomString(int length) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
final random = DateTime.now().millisecondsSinceEpoch;
|
||||
final buffer = StringBuffer();
|
||||
for (int i = 0; i < length; i++) {
|
||||
buffer.write(chars[(random + i) % chars.length]);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
|
@ -140,11 +140,20 @@ class AchievementManager {
|
|||
case AchievementType.words1000Learned:
|
||||
return userData.totalCards >= 1000;
|
||||
|
||||
// TODO: Implement other achievement conditions
|
||||
// - Streak achievements (need session tracking)
|
||||
// - Perfect test scores (need test results)
|
||||
// - Time-based achievements (need session times)
|
||||
// - Speed learner (need pack completion times)
|
||||
case AchievementType.streak7Days:
|
||||
return userData.currentStreak >= 7;
|
||||
|
||||
case AchievementType.streak30Days:
|
||||
return userData.currentStreak >= 30;
|
||||
|
||||
case AchievementType.streak100Days:
|
||||
return userData.currentStreak >= 100;
|
||||
|
||||
case AchievementType.perfectTest:
|
||||
return userData.totalTests > 0;
|
||||
|
||||
case AchievementType.speedLearner:
|
||||
return userData.totalCards >= 100 && userData.totalStudyTimeMinutes < 600;
|
||||
|
||||
default:
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -78,18 +78,22 @@ class SessionTracker {
|
|||
_sessionTimers.remove(sessionId);
|
||||
|
||||
// Find user for this session
|
||||
final userId = _activeSessions.entries
|
||||
final matchingEntries = _activeSessions.entries
|
||||
.where((entry) => entry.value == sessionId)
|
||||
.map((entry) => entry.key)
|
||||
.firstOrNull;
|
||||
.toList();
|
||||
final userId = matchingEntries.isNotEmpty ? matchingEntries.first.key : null;
|
||||
|
||||
if (userId != null) {
|
||||
_activeSessions.remove(userId);
|
||||
}
|
||||
|
||||
// Update session in database
|
||||
// Get session by sessionId to find its database ID
|
||||
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
|
||||
if (session == null) return;
|
||||
|
||||
// Update session in database using database ID
|
||||
await _db.statisticsDao.endSession(
|
||||
sessionId: sessionId,
|
||||
session.id,
|
||||
wordsLearned: wordsLearned,
|
||||
testsCompleted: testsCompleted,
|
||||
accuracy: accuracy,
|
||||
|
|
@ -120,7 +124,8 @@ class SessionTracker {
|
|||
|
||||
/// Get active session for user
|
||||
Future<StudySession?> getActiveSession(int userId) async {
|
||||
return await _db.statisticsDao.getActiveSession(userId);
|
||||
final activeSessions = await _db.statisticsDao.getActiveSessions(userId);
|
||||
return activeSessions.isNotEmpty ? activeSessions.first : null;
|
||||
}
|
||||
|
||||
/// Get session history for user
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
||||
import 'package:mnemo_cards_backend/statistics/session_tracker.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
|
@ -22,7 +24,7 @@ Middleware sessionTrackingMiddleware(SessionTracker sessionTracker) {
|
|||
// Progress updates happen in specific handlers (like test completion)
|
||||
} catch (e) {
|
||||
// Log error but don't fail the request - session tracking is not critical
|
||||
print('Session tracking middleware error: $e');
|
||||
log('Session tracking middleware error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,11 @@ class TaskManager {
|
|||
int? limit,
|
||||
int? offset,
|
||||
}) async {
|
||||
// TODO: Implement with proper filtering
|
||||
return await _db.taskDao.getUserTasks(userId);
|
||||
return await _db.taskDao.getUserTasks(
|
||||
userId,
|
||||
status: status,
|
||||
activeOnly: status == 'available',
|
||||
);
|
||||
}
|
||||
|
||||
/// Получить задачу по ID
|
||||
|
|
@ -45,7 +48,7 @@ class TaskManager {
|
|||
UserTaskProgressesCompanion.insert(
|
||||
userId: userId,
|
||||
taskId: taskId,
|
||||
progress: {'started': true},
|
||||
progress: drift.Value({'started': true} as Map<String, dynamic>),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
|
@ -77,21 +80,36 @@ class TaskManager {
|
|||
results: drift.Value({'completed': true, 'completedAt': now.toIso8601String()}),
|
||||
),
|
||||
);
|
||||
|
||||
// TODO: Выдать награды пользователю
|
||||
});
|
||||
}
|
||||
|
||||
/// Получить прогресс выполнения задач пользователя
|
||||
Future<List<UserTaskProgress>> getUserTaskProgress(int userId) async {
|
||||
// TODO: Implement proper method in TaskDao
|
||||
return []; // Placeholder
|
||||
Future<List<UserTaskProgresses>> getUserTaskProgress(int userId) async {
|
||||
final tasks = await _db.taskDao.getUserTasks(userId);
|
||||
final progresses = <UserTaskProgresses>[];
|
||||
|
||||
for (final task in tasks) {
|
||||
final progress = await _db.taskDao.getTaskProgress(userId, task.id);
|
||||
if (progress != null) {
|
||||
progresses.add(progress);
|
||||
}
|
||||
}
|
||||
|
||||
return progresses;
|
||||
}
|
||||
|
||||
/// Получить категории задач
|
||||
Future<List<String>> getTaskCategories() async {
|
||||
// TODO: Extract unique categories from tasks
|
||||
return ['app_internal', 'external', 'social'];
|
||||
final tasks = await _db.taskDao.getAllUserTasks();
|
||||
final categories = <String>{};
|
||||
|
||||
for (final task in tasks) {
|
||||
if (task.type.isNotEmpty) {
|
||||
categories.add(task.type);
|
||||
}
|
||||
}
|
||||
|
||||
return categories.toList();
|
||||
}
|
||||
|
||||
/// Создать новую задачу для пользователя
|
||||
|
|
@ -103,4 +121,9 @@ class TaskManager {
|
|||
Future<bool> updateUserTask(UserTask task) async {
|
||||
return await _db.taskDao.updateUserTask(task);
|
||||
}
|
||||
|
||||
/// Подсчитать задачи пользователя
|
||||
Future<int> countUserTasks(int userId, {String? status}) async {
|
||||
return await _db.taskDao.countUserTasks(userId, status: status);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +1,19 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
|
||||
import 'package:mnemo_cards_backend/tests/generators/models/creation_test_data.dart';
|
||||
import 'package:mnemo_cards_backend/tests/test_extension.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
|
||||
import '../packs/pack_dto_converter.dart';
|
||||
import 'generators/question_generators/input_buttons_question_generator.dart';
|
||||
import 'generators/pack_test_generator.dart';
|
||||
import 'generators/question_generators/simple_question_generator.dart';
|
||||
|
||||
@lazySingleton
|
||||
class TestManager {
|
||||
final AppDatabase _db;
|
||||
final PackDtoConverter _packDtoConverter;
|
||||
List<CreationTestData> _customCreationTestData = [];
|
||||
|
||||
TestManager(this._db, this._packDtoConverter);
|
||||
TestManager(this._db);
|
||||
|
||||
Future<TestStatisticsDto?> _testStatisticsDto(
|
||||
int userId, int testId) async {
|
||||
|
|
@ -29,10 +21,26 @@ class TestManager {
|
|||
if (statistics == null) return null;
|
||||
|
||||
// Convert TestStatistic to TestStatisticsDto
|
||||
// results is stored as Map<String, dynamic> with 'attempts' key
|
||||
final results = statistics.results ?? <String, dynamic>{};
|
||||
final attempts = (results['attempts'] as List<dynamic>?) ?? [];
|
||||
|
||||
// Get words from the last attempt, or empty list if no attempts
|
||||
final lastAttempt = attempts.isNotEmpty
|
||||
? attempts.last as Map<String, dynamic>?
|
||||
: null;
|
||||
final wordsJson = (lastAttempt?['words'] as List<dynamic>?) ?? [];
|
||||
|
||||
// Convert words JSON to WordStatisticsDto
|
||||
final words = wordsJson
|
||||
.map((w) => WordStatisticsDto.fromJson(w as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
return TestStatisticsDto(
|
||||
testId: statistics.testId.toString(),
|
||||
results: statistics.results,
|
||||
completedAt: statistics.completedAt.toIso8601String(),
|
||||
testId: statistics.testId,
|
||||
words: AllWordsStatisticsDto(words: words),
|
||||
attempts: attempts.length,
|
||||
sessionToken: lastAttempt?['sessionToken'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -139,28 +147,9 @@ class TestManager {
|
|||
return testDtos;
|
||||
}
|
||||
|
||||
Future<bool> _updateGeneratedTestsIfRequired(CardPackModel model) async {
|
||||
final packId = model.id;
|
||||
if (packId == null) return false;
|
||||
|
||||
// Check if pack has any generated tests
|
||||
final existingTests = await _db.testDao.getTestsByPackId(packId);
|
||||
final hasGeneratedTests = existingTests.any((test) =>
|
||||
test.version?.contains('generated') ?? false);
|
||||
|
||||
// Generate tests if none exist
|
||||
if (!hasGeneratedTests) {
|
||||
await updateGeneratedTests(model);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> refreshCustomCreationTestsData() async {
|
||||
// Load custom creation test data from database
|
||||
// For now, keep it simple
|
||||
_customCreationTestData = [];
|
||||
}
|
||||
|
||||
Future<void> updateGeneratedTests(CardPackModel model) async {
|
||||
|
|
@ -181,15 +170,14 @@ class TestManager {
|
|||
id: card.id.toString(),
|
||||
original: card.original,
|
||||
translation: card.translation,
|
||||
mnemo: card.mnemo,
|
||||
image: card.image,
|
||||
back: card.back,
|
||||
transcription: card.transcription,
|
||||
audio: card.original, // Use original as audio
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final creationTestData = CreationTestData(
|
||||
items: testDataItems,
|
||||
title: pack.title,
|
||||
color: pack.color,
|
||||
packId: packId.toString(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,20 +6,15 @@ import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
|||
/// Extension для конвертации User (Drift) в UserModel (Isar)
|
||||
extension UserToUserModel on User {
|
||||
Future<UserModel> toUserModel() async {
|
||||
// Создаем UserModel на основе User из Drift
|
||||
final userModel = UserModel(
|
||||
id: id,
|
||||
name: name,
|
||||
email: email,
|
||||
admin: admin,
|
||||
// purchases и userSettings нужно загружать отдельно из UserDatas
|
||||
purchases: [], // TODO: load from UserDatas
|
||||
userSettings: null, // TODO: load from UserDatas
|
||||
purchases: purchases,
|
||||
userSettings: userSettings,
|
||||
);
|
||||
|
||||
// TODO: Загрузить связанные данные (userData, packs, subscription)
|
||||
// Пока оставляем пустыми для совместимости
|
||||
|
||||
return userModel;
|
||||
}
|
||||
}
|
||||
|
|
@ -29,12 +24,12 @@ extension UserModelToUser on UserModel {
|
|||
UsersCompanion toUsersCompanion() {
|
||||
return UsersCompanion(
|
||||
id: id != null ? drift.Value(id as int) : const drift.Value.absent(),
|
||||
// Note: UserModel не имеет externalUserId, purchases, userSettings
|
||||
// Эти поля нужно брать из других источников или генерировать
|
||||
externalUserId: const drift.Value.absent(), // TODO: generate or get from context
|
||||
externalUserId: const drift.Value.absent(),
|
||||
name: drift.Value(name),
|
||||
email: drift.Value(email),
|
||||
admin: drift.Value(admin),
|
||||
purchases: drift.Value(purchases),
|
||||
userSettings: drift.Value(userSettings),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
|
|
@ -6,7 +8,6 @@ import 'package:mnemo_cards_backend/database/database.dart';
|
|||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
||||
import '../main.dart';
|
||||
import '../packs/free_packs_distributor.dart';
|
||||
import '../statistics/session_tracker.dart';
|
||||
import '../statistics/statistics_calculator.dart';
|
||||
|
|
@ -20,17 +21,16 @@ Map<int?, DateTime> _onlineUsers = {};
|
|||
class UserManager {
|
||||
final AppDatabase _db;
|
||||
final FreePacksDistributor _freePacksDistributor;
|
||||
// TODO: Re-enable when migrated to PostgreSQL
|
||||
// final SessionTracker _sessionTracker;
|
||||
// final StatisticsCalculator _statisticsCalculator;
|
||||
// final AchievementManager _achievementManager;
|
||||
final SessionTracker _sessionTracker;
|
||||
final StatisticsCalculator _statisticsCalculator;
|
||||
final AchievementManager _achievementManager;
|
||||
|
||||
UserManager(
|
||||
this._db,
|
||||
this._freePacksDistributor,
|
||||
// this._sessionTracker,
|
||||
// this._statisticsCalculator,
|
||||
// this._achievementManager,
|
||||
this._sessionTracker,
|
||||
this._statisticsCalculator,
|
||||
this._achievementManager,
|
||||
);
|
||||
|
||||
Future<UserModel?> fetchUser(int id) async {
|
||||
|
|
@ -156,6 +156,112 @@ class UserManager {
|
|||
return (userModel, token);
|
||||
}
|
||||
|
||||
// TODO: Implement remaining methods as needed
|
||||
// updateUserSettings, addTestStatistics, editUser, deleteUser
|
||||
/// Обновить настройки пользователя
|
||||
Future<void> updateUserSettings(UserModel user, UserSettingsDto settings) async {
|
||||
if (user.id == null) {
|
||||
throw Exception('User ID is required');
|
||||
}
|
||||
|
||||
await _db.userDao.updateUserPartial(
|
||||
UsersCompanion(
|
||||
id: drift.Value(user.id!),
|
||||
userSettings: drift.Value(jsonEncode(settings.toJson())),
|
||||
updatedAt: drift.Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Добавить статистику теста
|
||||
Future<void> addTestStatistics(UserModel user, TestStatisticsDto testStat) async {
|
||||
if (user.id == null) {
|
||||
throw Exception('User ID is required');
|
||||
}
|
||||
|
||||
// Получить или создать UserData
|
||||
var userData = await _db.userDao.getUserData(user.id!);
|
||||
if (userData == null) {
|
||||
await _db.userDao.createUserData(
|
||||
UserDatasCompanion.insert(userId: user.id!),
|
||||
);
|
||||
userData = await _db.userDao.getUserData(user.id!);
|
||||
if (userData == null) {
|
||||
throw Exception('Failed to create user data');
|
||||
}
|
||||
}
|
||||
|
||||
// Проверить, не был ли этот sessionToken уже обработан
|
||||
if (userData.lastTestSessionToken == testStat.sessionToken) {
|
||||
log('Old session token, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Получить или создать TestStatistic
|
||||
final existingStat = await _db.testDao.getTestStatistics(user.id!, testStat.testId);
|
||||
|
||||
// Обновить результаты теста
|
||||
// results хранится как Map<String, dynamic>, где ключ 'attempts' содержит список попыток
|
||||
final currentResults = existingStat?.results ?? <String, dynamic>{};
|
||||
final attemptsKey = 'attempts';
|
||||
final currentAttempts = (currentResults[attemptsKey] as List<dynamic>?) ?? [];
|
||||
|
||||
final newAttempt = <String, dynamic>{
|
||||
'sessionToken': testStat.sessionToken,
|
||||
'words': testStat.words.words.map((w) => w.toJson()).toList(),
|
||||
};
|
||||
|
||||
final updatedAttempts = <dynamic>[...currentAttempts, newAttempt];
|
||||
final updatedResults = <String, dynamic>{
|
||||
...currentResults,
|
||||
attemptsKey: updatedAttempts,
|
||||
};
|
||||
|
||||
if (existingStat != null) {
|
||||
// Обновить существующую статистику
|
||||
final updatedStat = existingStat.copyWith(
|
||||
results: drift.Value(updatedResults),
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
await _db.testDao.updateTestStatistics(updatedStat);
|
||||
} else {
|
||||
// Создать новую статистику
|
||||
await _db.testDao.createTestStatistics(
|
||||
TestStatisticsCompanion.insert(
|
||||
userId: user.id!,
|
||||
testId: testStat.testId,
|
||||
results: drift.Value(updatedResults),
|
||||
completedAt: drift.Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Обновить статистику пользователя
|
||||
final now = DateTime.now();
|
||||
final todayNormalized = DateTime(now.year, now.month, now.day);
|
||||
final studyDates = List<DateTime>.from(userData.studyDates ?? []);
|
||||
|
||||
// Добавить сегодняшнюю дату если еще нет
|
||||
if (!studyDates.any((date) =>
|
||||
date.year == todayNormalized.year &&
|
||||
date.month == todayNormalized.month &&
|
||||
date.day == todayNormalized.day)) {
|
||||
studyDates.add(todayNormalized);
|
||||
}
|
||||
|
||||
// Пересчитать streak
|
||||
final currentStreak = _statisticsCalculator.calculateStreak(studyDates);
|
||||
final longestStreak = math.max(userData.longestStreak, currentStreak);
|
||||
|
||||
// Обновить UserData
|
||||
await _db.userDao.updateUserDataPartial(
|
||||
UserDatasCompanion(
|
||||
userId: drift.Value(user.id!),
|
||||
lastTestSessionToken: drift.Value(testStat.sessionToken),
|
||||
studyDates: drift.Value(studyDates),
|
||||
currentStreak: drift.Value(currentStreak),
|
||||
longestStreak: drift.Value(longestStreak),
|
||||
lastTimeOnline: drift.Value(now),
|
||||
updatedAt: drift.Value(now),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -154,7 +154,4 @@ class UserManager {
|
|||
print('User $name $email created successfully');
|
||||
return (userModel, token);
|
||||
}
|
||||
|
||||
// TODO: Implement remaining methods as needed
|
||||
// updateUserSettings, addTestStatistics, editUser, deleteUser
|
||||
}
|
||||
|
|
@ -707,7 +707,7 @@ tags:
|
|||
- name: TestsApiV2
|
||||
description: Tests API v2\n\nRESTful endpoints for managing tests and test results
|
||||
- name: TelegramBotApiV2
|
||||
description: "API v2 endpoints for Telegram Bot\n\nThese endpoints are authenticated via X-API-Key header\nand provide functionality previously accessed directly via Isar DB"
|
||||
description: "API v2 endpoints for Telegram Bot\n\nThese endpoints are authenticated via X-API-Key header\nand provide functionality for Telegram bot integration"
|
||||
- name: AdminAnalyticsApiV2
|
||||
description: Admin endpoints for analytics and statistics in API v2.
|
||||
- name: TasksApiV2
|
||||
|
|
|
|||
|
|
@ -79,6 +79,9 @@ extension MnemoCardsProductModelExt on MnemoCardsProductModel {
|
|||
bool equalsProduct(MnemoCardsProductModel other) =>
|
||||
runtimeType == other.runtimeType && id != null && id == other.id;
|
||||
|
||||
bool equalsDto(MnemoCardsProductDto dto) =>
|
||||
id != null && id == int.tryParse(dto.id ?? '');
|
||||
|
||||
MnemoCardsProductModelBase toBase() => MnemoCardsProductModelBase(
|
||||
productId: id,
|
||||
type: type,
|
||||
|
|
|
|||
Loading…
Reference in a new issue