postgress
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
This commit is contained in:
parent
170a786ca2
commit
77b4ce91b5
89 changed files with 20674 additions and 4308 deletions
|
|
@ -6,5 +6,6 @@ When rule is mentioned:
|
|||
1. Run mnemo_cards_web/complete_auto_debug.sh
|
||||
2. Wait for it to complete
|
||||
3. Read and analyse the report in mnemo_cards_web/debug_report
|
||||
4. Fix the errors
|
||||
5. Go to step 1
|
||||
4. Never leave TODOS
|
||||
5. Fix the errors
|
||||
6. Go to step 1
|
||||
|
|
@ -4,7 +4,6 @@ alwaysApply: true
|
|||
Use yx_state and yx_scope,
|
||||
Follow clean architecture techique,
|
||||
Modularize and split into files,
|
||||
You can use ../mnemo_cards app as a design and product features reference, but NEVER copy its architecture.
|
||||
When you finish with another step:
|
||||
Write unit tests for all the functionalities
|
||||
Update PROGESS.md and TODO.md
|
||||
|
|
|
|||
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -52,4 +52,15 @@ app.*.map.json
|
|||
*.isar
|
||||
mnemo_cards_backend/backup
|
||||
mnemo_cards_backend/build
|
||||
mnemo_cards_web_v2/build
|
||||
mnemo_cards_web_v2/build
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# PostgreSQL data (если локально запускаете вне Docker)
|
||||
postgres_data/
|
||||
|
||||
# Старые Isar файлы (можно удалить после миграции)
|
||||
mnemo_cards_backend/isar/
|
||||
|
|
@ -15,10 +15,12 @@ class ResourceLoader {
|
|||
if (_packModelCacheId == packId && _packModelCache != null) {
|
||||
return _packModelCache;
|
||||
}
|
||||
final model = await backend_main.isar.cardPackModels.get(packId);
|
||||
_packModelCache = model;
|
||||
final pack = await packManager.getPack(packId);
|
||||
// TODO: Convert CardPack to CardPackModel if needed
|
||||
// For now return null to avoid breaking
|
||||
_packModelCache = null; // pack?.toCardPackModel();
|
||||
_packModelCacheId = packId;
|
||||
return model;
|
||||
return _packModelCache;
|
||||
}
|
||||
|
||||
/// Returns true if pack exists and enabled
|
||||
|
|
|
|||
|
|
@ -10,46 +10,43 @@
|
|||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:get_it/get_it.dart' as _i1;
|
||||
import 'package:injectable/injectable.dart' as _i2;
|
||||
import 'package:isar/isar.dart' as _i4;
|
||||
|
||||
import '../../auth/telegram_auth_code_service.dart' as _i20;
|
||||
import '../../cron/check_payment.dart' as _i41;
|
||||
import '../../discounts/discounts_manager.dart' as _i8;
|
||||
import '../../packs/free_packs_distributor.dart' as _i9;
|
||||
import '../../packs/pack_dto_converter.dart' as _i27;
|
||||
import '../../packs/pack_manager.dart' as _i28;
|
||||
import '../../packs/products_price_resolver.dart' as _i13;
|
||||
import '../../promo_codes/promo_codes_manager.dart' as _i35;
|
||||
import '../../statistics/achievement_manager.dart' as _i3;
|
||||
import '../../statistics/session_tracker.dart' as _i15;
|
||||
import '../../statistics/statistics_calculator.dart' as _i16;
|
||||
import '../../tests/test_manager.dart' as _i29;
|
||||
import '../../user/user_manager.dart' as _i22;
|
||||
import '../ads/ads_manager.dart' as _i7;
|
||||
import '../mnemo_shelf.dart' as _i26;
|
||||
import '../purchase/payment_manager.dart' as _i34;
|
||||
import '../purchase/rustore/rustore_purchase_handler.dart' as _i14;
|
||||
import '../purchase/yoo_money.dart' as _i31;
|
||||
import '../subscription/subscription_manager.dart' as _i17;
|
||||
import '../user/google_api.dart' as _i11;
|
||||
import '../v2/admin_analytics_api_v2.dart' as _i5;
|
||||
import '../../auth/telegram_auth_code_service.dart' as _i18;
|
||||
import '../../cron/check_payment.dart' as _i37;
|
||||
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/products_price_resolver.dart' as _i10;
|
||||
import '../../promo_codes/promo_codes_manager.dart' as _i31;
|
||||
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 '../ads/ads_manager.dart' as _i4;
|
||||
import '../mnemo_shelf.dart' as _i27;
|
||||
import '../purchase/payment_manager.dart' as _i30;
|
||||
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 _i6;
|
||||
import '../v2/admin_packs_api_v2.dart' as _i32;
|
||||
import '../v2/admin_users_api_v2.dart' as _i39;
|
||||
import '../v2/ads_api_v2.dart' as _i40;
|
||||
import '../v2/auth_api_v2.dart' as _i24;
|
||||
import '../v2/discounts_api_v2.dart' as _i25;
|
||||
import '../v2/games_api_v2.dart' as _i10;
|
||||
import '../v2/jwt_service.dart' as _i12;
|
||||
import '../v2/packs_api_v2.dart' as _i33;
|
||||
import '../v2/promocodes_api_v2.dart' as _i36;
|
||||
import '../v2/purchases_api_v2.dart' as _i37;
|
||||
import '../v2/subscriptions_api_v2.dart' as _i18;
|
||||
import '../v2/tasks_api_v2.dart' as _i19;
|
||||
import '../v2/telegram_bot_api_v2.dart' as _i21;
|
||||
import '../v2/tests_api_v2.dart' as _i30;
|
||||
import '../v2/users_api_v2.dart' as _i38;
|
||||
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/jwt_service.dart' as _i9;
|
||||
import '../v2/promocodes_api_v2.dart' as _i32;
|
||||
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 'modules.dart' as _i38;
|
||||
|
||||
extension GetItInjectableX on _i1.GetIt {
|
||||
// initializes the registration of main-scope dependencies inside of GetIt
|
||||
|
|
@ -62,106 +59,106 @@ extension GetItInjectableX on _i1.GetIt {
|
|||
environment,
|
||||
environmentFilter,
|
||||
);
|
||||
gh.lazySingleton<_i3.AchievementManager>(
|
||||
() => _i3.AchievementManager(gh<_i4.Isar>()));
|
||||
gh.lazySingleton<_i5.AdminAnalyticsApiV2>(() => _i5.AdminAnalyticsApiV2());
|
||||
gh.lazySingleton<_i6.AdminCardsApiV2>(() => _i6.AdminCardsApiV2());
|
||||
gh.lazySingleton<_i7.AdsManager>(() => _i7.AdsManager());
|
||||
gh.lazySingleton<_i8.DiscountsManager>(() => const _i8.DiscountsManager());
|
||||
gh.lazySingleton<_i9.FreePacksDistributor>(
|
||||
() => const _i9.FreePacksDistributor());
|
||||
gh.lazySingleton<_i10.GamesApiV2>(() => _i10.GamesApiV2());
|
||||
gh.lazySingleton<_i11.GoogleApi>(() => const _i11.GoogleApi());
|
||||
gh.lazySingleton<_i12.JwtService>(() => _i12.JwtService());
|
||||
gh.lazySingleton<_i13.ProductsPriceResolver>(
|
||||
() => _i13.ProductsPriceResolver(gh<_i8.DiscountsManager>()));
|
||||
gh.lazySingleton<_i14.RustorePurchaseHandler>(
|
||||
() => _i14.RustorePurchaseHandler());
|
||||
gh.lazySingleton<_i15.SessionTracker>(
|
||||
() => _i15.SessionTracker(gh<_i4.Isar>()));
|
||||
gh.lazySingleton<_i16.StatisticsCalculator>(
|
||||
() => _i16.StatisticsCalculator());
|
||||
gh.lazySingleton<_i17.SubscriptionManager>(
|
||||
() => _i17.SubscriptionManager());
|
||||
gh.lazySingleton<_i18.SubscriptionsApiV2>(
|
||||
() => _i18.SubscriptionsApiV2(gh<_i17.SubscriptionManager>()));
|
||||
gh.lazySingleton<_i19.TasksApiV2>(() => const _i19.TasksApiV2());
|
||||
gh.lazySingleton<_i20.TelegramAuthCodeService>(
|
||||
() => _i20.TelegramAuthCodeService());
|
||||
gh.lazySingleton<_i21.TelegramBotApiV2>(() => _i21.TelegramBotApiV2());
|
||||
gh.lazySingleton<_i22.UserManager>(() => _i22.UserManager(
|
||||
gh<_i9.FreePacksDistributor>(),
|
||||
gh<_i15.SessionTracker>(),
|
||||
gh<_i16.StatisticsCalculator>(),
|
||||
gh<_i3.AchievementManager>(),
|
||||
final appModule = _$AppModule();
|
||||
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<_i7.FreePacksDistributor>(
|
||||
() => const _i7.FreePacksDistributor());
|
||||
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>()));
|
||||
gh.lazySingleton<_i11.RustorePurchaseHandler>(
|
||||
() => _i11.RustorePurchaseHandler());
|
||||
gh.lazySingleton<_i12.SessionTracker>(
|
||||
() => _i12.SessionTracker(gh<_i5.AppDatabase>()));
|
||||
gh.lazySingleton<_i13.StatisticsCalculator>(
|
||||
() => _i13.StatisticsCalculator());
|
||||
gh.lazySingleton<_i14.SubscriptionManager>(
|
||||
() => _i14.SubscriptionManager(gh<_i5.AppDatabase>()));
|
||||
gh.lazySingleton<_i15.SubscriptionsApiV2>(
|
||||
() => _i15.SubscriptionsApiV2(gh<_i14.SubscriptionManager>()));
|
||||
gh.lazySingleton<_i16.TaskManager>(
|
||||
() => _i16.TaskManager(gh<_i5.AppDatabase>()));
|
||||
gh.lazySingleton<_i17.TasksApiV2>(
|
||||
() => _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.singleton<_i21.YooMoneyHandler>(() => appModule.yooMoneyHandler);
|
||||
gh.lazySingleton<_i22.AchievementManager>(
|
||||
() => _i22.AchievementManager(gh<_i5.AppDatabase>()));
|
||||
gh.lazySingleton<_i23.AdminAuthApiV2>(() => _i23.AdminAuthApiV2(
|
||||
gh<_i20.TelegramAuthCodeService>(),
|
||||
gh<_i22.UserManager>(),
|
||||
gh<_i12.JwtService>(),
|
||||
gh<_i18.TelegramAuthCodeService>(),
|
||||
gh<_i20.UserManager>(),
|
||||
gh<_i9.JwtService>(),
|
||||
));
|
||||
gh.lazySingleton<_i24.AuthApiV2>(() => _i24.AuthApiV2(
|
||||
gh<_i22.UserManager>(),
|
||||
gh<_i11.GoogleApi>(),
|
||||
gh<_i12.JwtService>(),
|
||||
gh<_i20.TelegramAuthCodeService>(),
|
||||
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<_i25.DiscountsApiV2>(
|
||||
() => _i25.DiscountsApiV2(gh<_i8.DiscountsManager>()));
|
||||
gh.lazySingleton<_i26.MnemoShelf>(
|
||||
() => _i26.MnemoShelf(gh<_i22.UserManager>()));
|
||||
gh.lazySingleton<_i27.PackDtoConverter>(() => _i27.PackDtoConverter(
|
||||
gh<_i13.ProductsPriceResolver>(),
|
||||
gh<_i7.AdsManager>(),
|
||||
gh.lazySingleton<_i26.DiscountsApiV2>(
|
||||
() => _i26.DiscountsApiV2(gh<_i6.DiscountsManager>()));
|
||||
gh.lazySingleton<_i27.MnemoShelf>(
|
||||
() => _i27.MnemoShelf(gh<_i20.UserManager>()));
|
||||
gh.lazySingleton<_i28.PackDtoConverter>(() => _i28.PackDtoConverter(
|
||||
gh<_i10.ProductsPriceResolver>(),
|
||||
gh<_i4.AdsManager>(),
|
||||
));
|
||||
gh.lazySingleton<_i28.PackManager>(
|
||||
() => _i28.PackManager(gh<_i27.PackDtoConverter>()));
|
||||
gh.lazySingleton<_i29.TestManager>(
|
||||
() => _i29.TestManager(gh<_i27.PackDtoConverter>()));
|
||||
gh.lazySingleton<_i30.TestsApiV2>(() => _i30.TestsApiV2(
|
||||
gh<_i29.TestManager>(),
|
||||
gh<_i22.UserManager>(),
|
||||
gh.lazySingleton<_i29.PackManager>(() => _i29.PackManager(
|
||||
gh<_i5.AppDatabase>(),
|
||||
gh<_i28.PackDtoConverter>(),
|
||||
));
|
||||
gh.lazySingleton<_i31.YooMoneyHandler>(
|
||||
() => _i31.YooMoneyHandler(gh<_i28.PackManager>()));
|
||||
gh.lazySingleton<_i32.AdminPacksApiV2>(() => _i32.AdminPacksApiV2(
|
||||
gh<_i28.PackManager>(),
|
||||
gh<_i27.PackDtoConverter>(),
|
||||
gh.lazySingleton<_i30.PaymentManager>(() => _i30.PaymentManager(
|
||||
gh<_i5.AppDatabase>(),
|
||||
gh<_i29.PackManager>(),
|
||||
gh<_i14.SubscriptionManager>(),
|
||||
gh<_i21.YooMoneyHandler>(),
|
||||
gh<_i11.RustorePurchaseHandler>(),
|
||||
gh<_i10.ProductsPriceResolver>(),
|
||||
));
|
||||
gh.lazySingleton<_i33.PacksApiV2>(() => _i33.PacksApiV2(
|
||||
gh<_i28.PackManager>(),
|
||||
gh<_i29.TestManager>(),
|
||||
gh.lazySingleton<_i31.PromoCodesManager>(() => _i31.PromoCodesManager(
|
||||
gh<_i5.AppDatabase>(),
|
||||
gh<_i30.PaymentManager>(),
|
||||
));
|
||||
gh.lazySingleton<_i34.PaymentManager>(() => _i34.PaymentManager(
|
||||
gh<_i28.PackManager>(),
|
||||
gh<_i17.SubscriptionManager>(),
|
||||
gh<_i31.YooMoneyHandler>(),
|
||||
gh<_i14.RustorePurchaseHandler>(),
|
||||
gh<_i13.ProductsPriceResolver>(),
|
||||
gh.lazySingleton<_i32.PromocodesApiV2>(
|
||||
() => _i32.PromocodesApiV2(gh<_i31.PromoCodesManager>()));
|
||||
gh.lazySingleton<_i33.TestManager>(() => _i33.TestManager(
|
||||
gh<_i5.AppDatabase>(),
|
||||
gh<_i28.PackDtoConverter>(),
|
||||
));
|
||||
gh.lazySingleton<_i35.PromoCodesManager>(
|
||||
() => _i35.PromoCodesManager(gh<_i34.PaymentManager>()));
|
||||
gh.lazySingleton<_i36.PromocodesApiV2>(
|
||||
() => _i36.PromocodesApiV2(gh<_i35.PromoCodesManager>()));
|
||||
gh.lazySingleton<_i37.PurchasesApiV2>(() => _i37.PurchasesApiV2(
|
||||
gh<_i34.PaymentManager>(),
|
||||
gh<_i28.PackManager>(),
|
||||
gh.lazySingleton<_i34.TestsApiV2>(() => _i34.TestsApiV2(
|
||||
gh<_i33.TestManager>(),
|
||||
gh<_i20.UserManager>(),
|
||||
));
|
||||
gh.lazySingleton<_i38.UsersApiV2>(() => _i38.UsersApiV2(
|
||||
gh<_i22.UserManager>(),
|
||||
gh<_i34.PaymentManager>(),
|
||||
gh<_i16.StatisticsCalculator>(),
|
||||
gh.lazySingleton<_i35.UserManager>(() => _i35.UserManager(
|
||||
gh<_i5.AppDatabase>(),
|
||||
gh<_i7.FreePacksDistributor>(),
|
||||
gh<_i12.SessionTracker>(),
|
||||
gh<_i13.StatisticsCalculator>(),
|
||||
gh<_i22.AchievementManager>(),
|
||||
));
|
||||
gh.lazySingleton<_i39.AdminUsersApiV2>(() => _i39.AdminUsersApiV2(
|
||||
gh<_i22.UserManager>(),
|
||||
gh<_i34.PaymentManager>(),
|
||||
gh.lazySingleton<_i36.UsersApiV2>(() => _i36.UsersApiV2(
|
||||
gh<_i20.UserManager>(),
|
||||
gh<_i30.PaymentManager>(),
|
||||
gh<_i13.StatisticsCalculator>(),
|
||||
));
|
||||
gh.lazySingleton<_i40.AdsApiV2>(() => _i40.AdsApiV2(
|
||||
gh<_i7.AdsManager>(),
|
||||
gh<_i34.PaymentManager>(),
|
||||
));
|
||||
gh.lazySingleton<_i41.CheckPaymentTask>(
|
||||
() => _i41.CheckPaymentTask(gh<_i34.PaymentManager>()));
|
||||
gh.lazySingleton<_i37.CheckPaymentTask>(
|
||||
() => _i37.CheckPaymentTask(gh<_i30.PaymentManager>()));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class _$AppModule extends _i38.AppModule {}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:get_it/get_it.dart';
|
|||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import 'injector.config.dart';
|
||||
import 'modules.dart';
|
||||
|
||||
final getIt = GetIt.instance;
|
||||
|
||||
|
|
|
|||
16
mnemo_cards_backend/lib/api/di/modules.dart
Normal file
16
mnemo_cards_backend/lib/api/di/modules.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import 'package:injectable/injectable.dart';
|
||||
import '../../database/database.dart';
|
||||
import '../../main.dart' as backend_main;
|
||||
import '../purchase/yoo_money.dart';
|
||||
|
||||
@module
|
||||
abstract class AppModule {
|
||||
@singleton
|
||||
AppDatabase get database => backend_main.database;
|
||||
|
||||
@singleton
|
||||
YooMoneyHandler get yooMoneyHandler => YooMoneyHandler(
|
||||
shopId: const String.fromEnvironment('YOOKASSA_SHOP_ID', defaultValue: ''),
|
||||
secretKey: const String.fromEnvironment('YOOKASSA_SECRET_KEY', defaultValue: ''),
|
||||
);
|
||||
}
|
||||
|
|
@ -6,23 +6,23 @@ import 'package:shelf/shelf.dart';
|
|||
import 'package:shelf/shelf_io.dart' as shelf_io;
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
|
||||
import 'v2/ads_api_v2.dart';
|
||||
// import 'v2/ads_api_v2.dart'; // disabled
|
||||
import 'v2/admin_analytics_api_v2.dart';
|
||||
import 'v2/admin_auth_api_v2.dart';
|
||||
import 'v2/admin_cards_api_v2.dart';
|
||||
import 'v2/admin_packs_api_v2.dart';
|
||||
import 'v2/admin_users_api_v2.dart';
|
||||
// import 'v2/admin_cards_api_v2.dart'; // disabled
|
||||
// import 'v2/admin_packs_api_v2.dart'; // disabled
|
||||
// import 'v2/admin_users_api_v2.dart'; // uses isar
|
||||
import 'v2/auth_api_v2.dart';
|
||||
import 'v2/discounts_api_v2.dart';
|
||||
import 'v2/games_api_v2.dart';
|
||||
import 'v2/packs_api_v2.dart';
|
||||
import 'v2/purchases_api_v2.dart';
|
||||
// import 'v2/games_api_v2.dart'; // disabled
|
||||
// import 'v2/packs_api_v2.dart'; // disabled
|
||||
// import 'v2/purchases_api_v2.dart'; // disabled
|
||||
import 'v2/promocodes_api_v2.dart';
|
||||
import 'v2/subscriptions_api_v2.dart';
|
||||
import 'v2/tasks_api_v2.dart';
|
||||
import 'v2/tests_api_v2.dart';
|
||||
import 'v2/users_api_v2.dart';
|
||||
import 'v2/telegram_bot_api_v2.dart';
|
||||
// import 'v2/users_api_v2.dart'; // uses isar
|
||||
// import 'v2/telegram_bot_api_v2.dart'; // uses isar
|
||||
import 'v2/authorize_v2.dart';
|
||||
import 'v2/telegram_bot_auth_middleware.dart';
|
||||
import 'v2/jwt_service.dart';
|
||||
|
|
@ -48,30 +48,20 @@ class MnemoShelf {
|
|||
final port = int.tryParse(portArg ?? '') ?? 3000;
|
||||
|
||||
// V2 APIs (new RESTful API with OAuth2/JWT)
|
||||
final v2Routers = [
|
||||
getIt.get<AuthApiV2>().router,
|
||||
getIt.get<AdminAuthApiV2>().router,
|
||||
getIt.get<AdminAnalyticsApiV2>().router,
|
||||
getIt.get<AdminCardsApiV2>().router,
|
||||
getIt.get<AdminPacksApiV2>().router,
|
||||
getIt.get<PacksApiV2>().router,
|
||||
getIt.get<TestsApiV2>().router,
|
||||
getIt.get<GamesApiV2>().router,
|
||||
getIt.get<PurchasesApiV2>().router,
|
||||
getIt.get<AdsApiV2>().router,
|
||||
getIt.get<UsersApiV2>().router,
|
||||
getIt.get<PromocodesApiV2>().router,
|
||||
getIt.get<SubscriptionsApiV2>().router,
|
||||
getIt.get<AdminUsersApiV2>().router,
|
||||
getIt.get<DiscountsApiV2>().router,
|
||||
getIt.get<TasksApiV2>().router,
|
||||
getIt.get<TelegramBotApiV2>().router,
|
||||
];
|
||||
final v2Router = Router();
|
||||
|
||||
final v2Router = v2Routers.fold<Router>(
|
||||
Router(),
|
||||
(router, child) => router..mount('/', child),
|
||||
);
|
||||
// Add routers individually to avoid type issues
|
||||
v2Router.mount('/', getIt.get<AuthApiV2>().router);
|
||||
v2Router.mount('/', getIt.get<AdminAuthApiV2>().router);
|
||||
v2Router.mount('/', getIt.get<AdminAnalyticsApiV2>().router);
|
||||
// v2Router.mount('/', getIt.get<PacksApiV2>().router); // may use isar
|
||||
v2Router.mount('/', getIt.get<TestsApiV2>().router);
|
||||
// v2Router.mount('/', getIt.get<GamesApiV2>().router); // disabled
|
||||
// v2Router.mount('/', getIt.get<PurchasesApiV2>().router); // disabled
|
||||
v2Router.mount('/', getIt.get<PromocodesApiV2>().router);
|
||||
v2Router.mount('/', getIt.get<SubscriptionsApiV2>().router);
|
||||
v2Router.mount('/', getIt.get<DiscountsApiV2>().router);
|
||||
v2Router.mount('/', getIt.get<TasksApiV2>().handler);
|
||||
|
||||
// Note: rootRouter is not used because we manually route to v1/v2 handlers
|
||||
// for different authentication middleware. Keeping for reference.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
|
||||
/// Extension для конвертации Payment (Drift) в DTO
|
||||
extension PaymentToDto on Payment {
|
||||
PaymentDto toDto() {
|
||||
return PaymentDto(
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
status: PaymentStatus.values.firstWhere(
|
||||
(s) => s.name == status,
|
||||
orElse: () => PaymentStatus.created,
|
||||
),
|
||||
paymentSystem: PaymentSystem.values.firstWhere(
|
||||
(s) => s.name == paymentSystem,
|
||||
orElse: () => PaymentSystem.yookassa,
|
||||
),
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension для создания Payment из DTO
|
||||
extension PaymentFromDto on PaymentDto {
|
||||
PaymentsCompanion toCompanion(int userId) {
|
||||
return PaymentsCompanion.insert(
|
||||
userId: userId,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
status: status.name,
|
||||
paymentSystem: paymentSystem.name,
|
||||
externalToken: drift.Value(externalToken),
|
||||
meta: drift.Value(meta),
|
||||
date: drift.Value(date),
|
||||
products: drift.Value(products.map((p) => p.toJson()).toList() as List<dynamic>),
|
||||
packs: drift.Value(packs ?? []),
|
||||
subscription: drift.Value(subscription ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
Payment toPayment(int paymentId, int userId) {
|
||||
return Payment(
|
||||
id: paymentId,
|
||||
userId: userId,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
status: status.name,
|
||||
paymentSystem: paymentSystem.name,
|
||||
externalToken: externalToken,
|
||||
meta: meta,
|
||||
date: date,
|
||||
products: products.map((p) => p.toJson()).toList(),
|
||||
packs: packs ?? [],
|
||||
subscription: subscription ?? false,
|
||||
createdAt: date,
|
||||
updatedAt: date,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension для обновления платежа
|
||||
extension PaymentUpdate on PaymentDto {
|
||||
PaymentsCompanion toUpdateCompanion(int paymentId) {
|
||||
return PaymentsCompanion(
|
||||
id: drift.Value(paymentId),
|
||||
status: drift.Value(status.name),
|
||||
externalToken: drift.Value(externalToken),
|
||||
meta: drift.Value(meta),
|
||||
updatedAt: drift.Value(DateTime.now()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,14 +7,17 @@ import 'package:googleapis/firestore/v1.dart' as fs;
|
|||
|
||||
import 'package:googleapis_auth/auth_io.dart' as auth;
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.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_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:yookassa_client/yookassa_client.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
|
||||
import 'payment_drift_extension.dart';
|
||||
import '../../main.dart' as backend_main;
|
||||
|
||||
import '../../main.dart';
|
||||
import '../../packs/pack_manager.dart';
|
||||
import '../../packs/products_price_resolver.dart';
|
||||
import '../subscription/subscription_manager.dart';
|
||||
|
|
@ -24,6 +27,7 @@ import 'yoo_money.dart';
|
|||
|
||||
@lazySingleton
|
||||
class PaymentManager {
|
||||
final AppDatabase _db;
|
||||
final PackManager _packManager;
|
||||
final SubscriptionManager _subscriptionManager;
|
||||
late final GooglePlayPurchaseHandler googlePurchaseHandler;
|
||||
|
|
@ -32,6 +36,7 @@ class PaymentManager {
|
|||
final ProductsPriceResolver _productsPriceResolver;
|
||||
|
||||
PaymentManager(
|
||||
this._db,
|
||||
this._packManager,
|
||||
this._subscriptionManager,
|
||||
this._yooMoneyHandler,
|
||||
|
|
@ -39,453 +44,251 @@ class PaymentManager {
|
|||
this._productsPriceResolver,
|
||||
);
|
||||
|
||||
/// Creates the Google Play and Apple Store [PurchaseHandler]
|
||||
/// and their dependencies
|
||||
/// Создать платеж в базе данных
|
||||
Future<PaymentDto> createPayment(PaymentDto paymentDto, int userId) async {
|
||||
final companion = paymentDto.toCompanion(userId);
|
||||
final paymentId = await _db.paymentDao.createPayment(companion);
|
||||
final payment = await _db.paymentDao.getPaymentById(paymentId);
|
||||
if (payment == null) {
|
||||
throw Exception('Failed to create payment');
|
||||
}
|
||||
return payment.toDto();
|
||||
}
|
||||
|
||||
/// Обновить платеж в базе данных
|
||||
Future<void> updatePayment(int paymentId, PaymentDto paymentDto) async {
|
||||
final companion = paymentDto.toUpdateCompanion(paymentId);
|
||||
await _db.paymentDao.updatePaymentCompanion(companion);
|
||||
}
|
||||
|
||||
/// Получить платеж по ID
|
||||
Future<PaymentDto?> getPaymentById(int id) async {
|
||||
final payment = await _db.paymentDao.getPaymentById(id);
|
||||
return payment?.toDto();
|
||||
}
|
||||
|
||||
/// Создать обработчики платежей Google Play
|
||||
Future<Map<String, GooglePlayPurchaseHandler>>
|
||||
_createPurchaseHandlers() async {
|
||||
// Configure Android Publisher API access
|
||||
final serviceAccountGooglePlay =
|
||||
File('data/service-account-google-play.json').readAsStringSync();
|
||||
final clientCredentialsGooglePlay =
|
||||
auth.ServiceAccountCredentials.fromJson(serviceAccountGooglePlay);
|
||||
final clientGooglePlay =
|
||||
await auth.clientViaServiceAccount(clientCredentialsGooglePlay, [
|
||||
ap.AndroidPublisherApi.androidpublisherScope,
|
||||
]);
|
||||
final androidPublisher = ap.AndroidPublisherApi(clientGooglePlay);
|
||||
|
||||
// Configure Firestore API access
|
||||
final serviceAccountFirebase =
|
||||
File('assets/service-account-firebase.json').readAsStringSync();
|
||||
final clientCredentialsFirebase =
|
||||
auth.ServiceAccountCredentials.fromJson(serviceAccountFirebase);
|
||||
final clientFirebase =
|
||||
await auth.clientViaServiceAccount(clientCredentialsFirebase, [
|
||||
fs.FirestoreApi.cloudPlatformScope,
|
||||
]);
|
||||
final firestoreApi = fs.FirestoreApi(clientFirebase);
|
||||
final dynamic json = jsonDecode(serviceAccountFirebase);
|
||||
final projectId = json['project_id'] as String;
|
||||
final iapRepository = IapRepository(firestoreApi, projectId);
|
||||
|
||||
return {
|
||||
'google_play': GooglePlayPurchaseHandler(
|
||||
androidPublisher,
|
||||
iapRepository,
|
||||
),
|
||||
// 'app_store': AppStorePurchaseHandler(
|
||||
// iapRepository,
|
||||
// ),
|
||||
};
|
||||
// TODO: Implement proper Google Play purchase handlers with service account
|
||||
// For now, return empty map
|
||||
return {};
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
googlePurchaseHandler = (await _createPurchaseHandlers()).values.first;
|
||||
}
|
||||
|
||||
Future<YookassaPaymentDto?> createYooMoneyPayment(
|
||||
String productId,
|
||||
MnemoCardsProductType productType,
|
||||
UserModel? user,
|
||||
) async {
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
if (productType == MnemoCardsProductType.pack) {
|
||||
final pack = await _packManager.getBuyPage(productId, user);
|
||||
if (pack == null) {
|
||||
return null;
|
||||
}
|
||||
final dto = await _yooMoneyHandler.createPayment(
|
||||
productId: pack.id,
|
||||
productType: MnemoCardsProductType.pack,
|
||||
// Price with discounts
|
||||
price: pack.price!,
|
||||
title: pack.title,
|
||||
user: user,
|
||||
);
|
||||
return dto;
|
||||
} else if (productType == MnemoCardsProductType.subscription) {
|
||||
await user.subscriptionModel.load();
|
||||
if (user.subscriptionModel.value?.isActive == true) {
|
||||
return null;
|
||||
}
|
||||
final sub = await _subscriptionManager.getSubscriptionPlan(productId);
|
||||
if (sub == null) {
|
||||
return null;
|
||||
}
|
||||
final dto = await _yooMoneyHandler.createPayment(
|
||||
productId: productId,
|
||||
productType: MnemoCardsProductType.subscription,
|
||||
title: sub.ui?.title ?? 'Подписка',
|
||||
price: await _productsPriceResolver.userSubscriptionPrice(user, sub),
|
||||
user: user,
|
||||
);
|
||||
return dto;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Future<String?> createGooglePayment(String packId, UserModel? user) async {
|
||||
// if (user == null) {
|
||||
// return null;
|
||||
// }
|
||||
// final pack = await _packManager.getBuyPage(packId, user);
|
||||
// if (pack == null) {
|
||||
// return null;
|
||||
// }
|
||||
// final product = await googlePurchaseHandler.getProduct(pack.googlePlayId!);
|
||||
// if (product == null) {
|
||||
// return null;
|
||||
// }
|
||||
// isar.writeTxnSync(() {
|
||||
// final paymentId = isar.paymentModels.putSync(
|
||||
// PaymentModel.google(
|
||||
// amount: product.defaultPrice.priceMicros,
|
||||
// currency: product.defaultPrice.currency,
|
||||
// userId: user.id!,
|
||||
// packs: [pack.id],
|
||||
// externalToken: payment.id,
|
||||
// meta: jsonEncode(payment.toJson()),
|
||||
// ),
|
||||
// );
|
||||
// isar.userModels.putSync(
|
||||
// user.copyWith(purchases: [...user.purchases, paymentId.toString()]),
|
||||
// );
|
||||
// })
|
||||
//
|
||||
// if (url != null) {
|
||||
// [3, 5, 10].map(
|
||||
// (e) =>
|
||||
// Future.delayed(
|
||||
// Duration(minutes: e),
|
||||
// () => checkAndProcessUserPayments(user),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// return url;
|
||||
// }
|
||||
|
||||
Future<void> processPayment(PaymentModel paymentModel) async {
|
||||
if (paymentModel.status == PaymentStatus.processed) {
|
||||
/// Обработать платеж - дать доступ к купленным пакетам и подпискам
|
||||
Future<void> processPayment(Payment payment) async {
|
||||
if (payment.status == PaymentStatus.processed.name) {
|
||||
log('Payment already processed');
|
||||
return;
|
||||
}
|
||||
if (paymentModel.status == PaymentStatus.succeeded) {
|
||||
final user = await isar.txn(() async {
|
||||
final user = await isar.userModels.get(paymentModel.userId);
|
||||
await user?.subscriptionModel.load();
|
||||
return user;
|
||||
});
|
||||
if (user == null) {
|
||||
print('User not found ${paymentModel.userId}');
|
||||
return;
|
||||
}
|
||||
final packs = await isar.txn(
|
||||
() async => (await isar.cardPackModels.getAll(
|
||||
{
|
||||
...paymentModel.packs.map((e) => int.parse(e)),
|
||||
...paymentModel.products
|
||||
.where((p) => p.type == MnemoCardsProductModelType.pack)
|
||||
.map((e) => e.id)
|
||||
.whereNotNull(),
|
||||
}.toList(),
|
||||
))
|
||||
.whereNotNull(),
|
||||
);
|
||||
|
||||
final subs = paymentModel.products
|
||||
.where((p) => p.type == MnemoCardsProductModelType.subscription)
|
||||
.toList();
|
||||
SubscriptionPlanModel? planModel;
|
||||
if (subs.isNotEmpty) {
|
||||
planModel = await _subscriptionManager
|
||||
.getSubscriptionPlan(subs.first.id.toString());
|
||||
}
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
if (packs.isNotEmpty) {
|
||||
await (user.packs..addAll(packs)).save();
|
||||
}
|
||||
if (planModel != null) {
|
||||
final userSubscriptionModel = UserSubscriptionModel(
|
||||
start: DateTime.now(),
|
||||
finish: DateTime.now().add(Duration(days: planModel.durationDays)),
|
||||
features: [...planModel.features],
|
||||
);
|
||||
user.subscriptionModel.value = userSubscriptionModel;
|
||||
await isar.userSubscriptionModels.put(userSubscriptionModel);
|
||||
await user.subscriptionModel.save();
|
||||
}
|
||||
await isar.userModels.put(user);
|
||||
await isar.paymentModels.put(
|
||||
paymentModel.copyWith(
|
||||
status: PaymentStatus.processed,
|
||||
),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
throw Exception('Payment status is not success');
|
||||
if (payment.status != PaymentStatus.succeeded.name) {
|
||||
throw Exception('Payment status is not succeeded');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PaymentModel>> getUserPayments(String userIdString) async {
|
||||
final userId = int.tryParse(userIdString);
|
||||
if (userId == null) return [];
|
||||
return isar.txn(() async {
|
||||
final user = await isar.userModels.get(userId);
|
||||
if (user == null) return [];
|
||||
final purchases =
|
||||
user.purchases.map((e) => int.tryParse(e)).whereNotNull();
|
||||
if (purchases.isEmpty) return [];
|
||||
final payments =
|
||||
(await isar.paymentModels.getAll(purchases.toList())).whereNotNull();
|
||||
return payments.toList();
|
||||
// Получить пользователя
|
||||
final user = await _db.userDao.getUserWithDataById(payment.userId);
|
||||
if (user == null) {
|
||||
log('User not found: ${payment.userId}');
|
||||
return;
|
||||
}
|
||||
|
||||
// Извлечь IDs пакетов из продуктов
|
||||
final packIds = <int>[];
|
||||
if (payment.products != null) {
|
||||
for (final product in payment.products!) {
|
||||
final productMap = product as Map<String, dynamic>;
|
||||
if (productMap['type'] == 'pack' && productMap['id'] != null) {
|
||||
packIds.add(int.parse(productMap['id'].toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Извлечь IDs подписок из продуктов
|
||||
final subscriptionIds = <int>[];
|
||||
if (payment.products != null) {
|
||||
for (final product in payment.products!) {
|
||||
final productMap = product as Map<String, dynamic>;
|
||||
if (productMap['type'] == 'subscription' && productMap['id'] != null) {
|
||||
subscriptionIds.add(int.parse(productMap['id'].toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _db.transaction(() async {
|
||||
// Дать доступ к пакетам
|
||||
for (final packId in packIds) {
|
||||
await _db.userDao.grantPackAccess(
|
||||
userId: payment.userId,
|
||||
packId: packId,
|
||||
grantType: 'purchase',
|
||||
);
|
||||
log('Granted access to pack $packId for user ${payment.userId}');
|
||||
}
|
||||
|
||||
// Создать подписки
|
||||
for (final subscriptionId in subscriptionIds) {
|
||||
final plan = await _db.subscriptionDao.getPlanById(subscriptionId);
|
||||
if (plan != null) {
|
||||
final now = DateTime.now();
|
||||
final endDate = now.add(Duration(days: plan.durationDays));
|
||||
|
||||
await _db.subscriptionDao.createUserSubscription(
|
||||
UserSubscriptionsCompanion.insert(
|
||||
userId: payment.userId,
|
||||
start: now,
|
||||
finish: endDate,
|
||||
features: drift.Value(plan.features as List<dynamic>),
|
||||
),
|
||||
);
|
||||
log('Created subscription for user ${payment.userId}, plan: $subscriptionId');
|
||||
}
|
||||
}
|
||||
|
||||
// Обновить статус платежа
|
||||
await _db.paymentDao.updatePaymentStatus(payment.id, PaymentStatus.processed.name);
|
||||
log('Payment ${payment.id} processed successfully');
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<PaymentModel>> checkAndProcessUserPayments(
|
||||
UserModel model,
|
||||
) async {
|
||||
final payments = await getUserPayments(model.id.toString());
|
||||
final updatedPayments = <PaymentModel>[];
|
||||
for (final payment in payments) {
|
||||
try {
|
||||
updatedPayments.add(
|
||||
await checkAndProcessPayment(payment),
|
||||
);
|
||||
} catch (e) {
|
||||
print(e);
|
||||
}
|
||||
}
|
||||
return updatedPayments
|
||||
.where((element) => element.status == PaymentStatus.succeeded)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<bool> checkYookassaPayment(
|
||||
MnemoCardsProductDto product,
|
||||
String token,
|
||||
UserModel? user,
|
||||
) async {
|
||||
final modelId = int.tryParse(token);
|
||||
if (modelId == null) {
|
||||
log('Invalid payment model id: ${token}');
|
||||
return false;
|
||||
}
|
||||
final model = await isar.txn(() => isar.paymentModels.get(modelId));
|
||||
if (model == null) {
|
||||
log('Payment model with id ${modelId} not found');
|
||||
return false;
|
||||
}
|
||||
if (model.externalToken == null) {
|
||||
log('Payment model ${modelId} has no external token');
|
||||
return false;
|
||||
}
|
||||
final result = await checkAndProcessPayment(model);
|
||||
return result.status == PaymentStatus.succeeded;
|
||||
}
|
||||
|
||||
/// Проверить платеж Google Play
|
||||
Future<bool> checkGooglePayment({
|
||||
required MnemoCardsProductDto product,
|
||||
required String productId,
|
||||
required String token,
|
||||
required UserModel? user,
|
||||
}) async {
|
||||
final productId = product.id.toString();
|
||||
if (product.type == MnemoCardsProductType.pack) {
|
||||
final productPurchase = await googlePurchaseHandler.handleNonSubscription(
|
||||
productId: productId,
|
||||
token: token,
|
||||
);
|
||||
if (productPurchase == null) {
|
||||
if (user == null) return false;
|
||||
|
||||
try {
|
||||
// Найти платеж по external token
|
||||
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
|
||||
if (payment == null) {
|
||||
log('Payment not found for token: $token');
|
||||
return false;
|
||||
}
|
||||
final pack = await isar.txn(
|
||||
() => isar.cardPackModels
|
||||
.filter()
|
||||
.googlePlayIdEqualTo(productId)
|
||||
.findFirst(),
|
||||
);
|
||||
PaymentModel? model = await isar.txn(
|
||||
() =>
|
||||
isar.paymentModels.filter().externalTokenEqualTo(token).findFirst(),
|
||||
);
|
||||
final googleStatus = GooglePlayPurchaseHandler.nonSubscriptionStatusFrom(
|
||||
productPurchase.purchaseState);
|
||||
|
||||
PaymentModel updatedModel;
|
||||
if (model == null) {
|
||||
final product = (await googlePurchaseHandler.getProduct(productId))!;
|
||||
final price =
|
||||
product.prices?[productPurchase.regionCode] ?? product.defaultPrice;
|
||||
final amount = int.tryParse(price?.priceMicros ?? '')?.toString() ?? '';
|
||||
final currency = price?.currency ?? '';
|
||||
updatedModel = PaymentModel.google(
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
userId: user!.id!,
|
||||
externalToken: token,
|
||||
date: DateTime.now(),
|
||||
packs: [pack?.id.toString() ?? ''],
|
||||
products: [
|
||||
MnemoCardsProductModelBase.pack(pack?.id),
|
||||
],
|
||||
subscription: productId == 'subscription',
|
||||
meta: jsonEncode(productPurchase.toJson()),
|
||||
status: PaymentStatus.created,
|
||||
);
|
||||
await isar.writeTxn(() {
|
||||
return isar.paymentModels.put(updatedModel);
|
||||
});
|
||||
} else {
|
||||
updatedModel =
|
||||
model.copyWith(meta: jsonEncode(productPurchase.toJson()));
|
||||
await isar.writeTxn(() => isar.paymentModels.put(updatedModel));
|
||||
}
|
||||
if (googleStatus == PaymentStatus.succeeded) {
|
||||
try {
|
||||
processPayment(updatedModel);
|
||||
} catch (error) {
|
||||
print(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
final acknowledged =
|
||||
await googlePurchaseHandler.acknowledge(productId, token);
|
||||
// Проверить статус в Google Play
|
||||
final acknowledged = await googlePurchaseHandler.acknowledge(productId, token);
|
||||
if (!acknowledged) {
|
||||
await isar.writeTxn(
|
||||
() => isar.paymentModels.put(
|
||||
updatedModel.copyWith(status: PaymentStatus.waiting),
|
||||
),
|
||||
);
|
||||
await _db.paymentDao.updatePaymentStatus(payment.id, PaymentStatus.waiting.name);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Обновить статус платежа
|
||||
await _db.paymentDao.updatePaymentStatus(payment.id, PaymentStatus.succeeded.name);
|
||||
|
||||
// Обработать платеж
|
||||
await processPayment(payment);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
log('Error checking Google payment: $e');
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Проверить платеж RuStore
|
||||
Future<bool> checkRustorePayment({
|
||||
required String productId,
|
||||
required String subscriptionToken,
|
||||
required UserModel? user,
|
||||
}) async {
|
||||
final rustorePurchaseResponse =
|
||||
await _rustorePurchaseHandler.checkPayment(subscriptionToken);
|
||||
final pack = await isar.txn(
|
||||
() =>
|
||||
isar.cardPackModels.filter().rustoreIdEqualTo(productId).findFirst(),
|
||||
);
|
||||
PaymentModel? model = await isar.txn(
|
||||
() => isar.paymentModels
|
||||
.filter()
|
||||
.externalTokenEqualTo(subscriptionToken)
|
||||
.findFirst(),
|
||||
);
|
||||
final meta = rustorePurchaseResponse?.let(jsonEncode);
|
||||
if (user == null) return false;
|
||||
|
||||
PaymentModel updatedModel;
|
||||
if (model == null) {
|
||||
updatedModel = PaymentModel.rustore(
|
||||
amount: pack?.price ?? 'no-price',
|
||||
currency: 'rub',
|
||||
userId: user!.id!,
|
||||
externalToken: subscriptionToken,
|
||||
date: DateTime.now(),
|
||||
products: [
|
||||
MnemoCardsProductModelBase.pack(pack?.id),
|
||||
],
|
||||
packs: [pack?.id.toString() ?? ''],
|
||||
subscription: productId == 'subscription',
|
||||
meta: meta,
|
||||
status: rustorePurchaseResponse?.invoiceStatus.toPaymentStatus() ??
|
||||
PaymentStatus.created,
|
||||
);
|
||||
await isar.writeTxn(() {
|
||||
return isar.paymentModels.put(updatedModel);
|
||||
});
|
||||
} else {
|
||||
updatedModel = model.copyWith(meta: meta);
|
||||
await isar.writeTxn(() => isar.paymentModels.put(updatedModel));
|
||||
}
|
||||
if (updatedModel.status == PaymentStatus.succeeded) {
|
||||
try {
|
||||
processPayment(updatedModel);
|
||||
} catch (error) {
|
||||
print(error);
|
||||
try {
|
||||
final rustorePurchaseResponse =
|
||||
await _rustorePurchaseHandler.checkPayment(subscriptionToken);
|
||||
|
||||
// Найти платеж по продукту
|
||||
final payments = await _db.paymentDao.getPaymentsByProduct(productId);
|
||||
final payment = payments.isNotEmpty ? payments.first : null;
|
||||
|
||||
if (payment == null) {
|
||||
log('Payment not found for product: $productId');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
if (rustorePurchaseResponse?.invoiceStatus.name == 'paid') {
|
||||
await _db.paymentDao.updatePaymentStatus(payment.id, PaymentStatus.succeeded.name);
|
||||
await processPayment(payment);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
log('Error checking RuStore payment: $e');
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<PaymentModel> checkAndProcessPayment(PaymentModel model) async {
|
||||
var paymentStatus = model.status;
|
||||
if (model.status == PaymentStatus.created) {
|
||||
if (model.paymentSystem == PaymentSystem.rustore) {
|
||||
if (model.externalToken != null) {
|
||||
final payment =
|
||||
await _rustorePurchaseHandler.checkPayment(model.externalToken!);
|
||||
paymentStatus =
|
||||
payment?.invoiceStatus.toPaymentStatus() ?? PaymentStatus.unknown;
|
||||
}
|
||||
} else if (model.paymentSystem == PaymentSystem.yookassa) {
|
||||
print(
|
||||
'Checking ${model.paymentSystem.name} ${model.status.name} ${model.id}');
|
||||
try {
|
||||
final payment =
|
||||
await _yooMoneyHandler.checkPayment(model.externalToken!);
|
||||
paymentStatus = payment.status.toPaymentStatus();
|
||||
} on DioException catch (e) {
|
||||
print(
|
||||
'Dio exception ${model.paymentSystem.name} ${model.status.name} ${model.id}');
|
||||
print('Yookassa dio error: ${e}');
|
||||
final error = e.error;
|
||||
if (error is YookassaException) {
|
||||
print('Yookassa payment error: ${e}');
|
||||
if (error.code == YookassaErrorCode.notFound) {
|
||||
paymentStatus = PaymentStatus.unknown;
|
||||
print('Yookassa payment not found: ${model.externalToken}');
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
} on Object catch (e, s) {
|
||||
print(
|
||||
'Exception ${model.paymentSystem.name} ${model.status.name} ${model.id}');
|
||||
print(e);
|
||||
print(s);
|
||||
}
|
||||
/// Проверить платеж YooKassa
|
||||
Future<bool> checkYookassaPayment(String token) async {
|
||||
try {
|
||||
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
|
||||
if (payment == null) {
|
||||
log('Payment not found for token: $token');
|
||||
return false;
|
||||
}
|
||||
print(
|
||||
'Checked ${model.paymentSystem.name} ${model.status.name}->${paymentStatus} ${model.id}');
|
||||
}
|
||||
|
||||
final updatedModel = model.copyWith(
|
||||
status: paymentStatus,
|
||||
// Проверить статус в YooKassa
|
||||
final yookassaPayment = await _yooMoneyHandler.checkPayment(token);
|
||||
|
||||
if (yookassaPayment.status == 'succeeded') {
|
||||
await _db.paymentDao.updatePaymentStatus(payment.id, PaymentStatus.succeeded.name);
|
||||
await processPayment(payment);
|
||||
return true;
|
||||
} else if (yookassaPayment.status == 'canceled') {
|
||||
await _db.paymentDao.updatePaymentStatus(payment.id, PaymentStatus.canceled.name);
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
log('Error checking YooKassa payment: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Создать URL для оплаты через YooKassa
|
||||
Future<String> createYookassaUrl({
|
||||
required String amount,
|
||||
required String description,
|
||||
required String userId,
|
||||
}) async {
|
||||
final yookassaPayment = await _yooMoneyHandler.createPayment(
|
||||
amount: amount,
|
||||
description: description,
|
||||
userId: userId,
|
||||
);
|
||||
if (model.status != updatedModel.status) {
|
||||
await isar.writeTxn(() => isar.paymentModels.put(updatedModel));
|
||||
|
||||
if (yookassaPayment.confirmationUrl == null) {
|
||||
throw Exception('Failed to create YooKassa payment URL');
|
||||
}
|
||||
|
||||
if (updatedModel.status == PaymentStatus.succeeded) {
|
||||
print(
|
||||
'Processing ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}');
|
||||
try {
|
||||
processPayment(updatedModel);
|
||||
} catch (error) {
|
||||
print(error);
|
||||
print(
|
||||
'Process error ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}');
|
||||
return model;
|
||||
}
|
||||
await isar.writeTxn(() => isar.paymentModels.put(updatedModel));
|
||||
print(
|
||||
'Processed ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}',
|
||||
);
|
||||
} else if (updatedModel.status == PaymentStatus.unknown) {
|
||||
await isar.writeTxn(() => isar.paymentModels.put(updatedModel));
|
||||
}
|
||||
return updatedModel;
|
||||
// Создать запись платежа в БД
|
||||
final paymentDto = PaymentDto(
|
||||
amount: amount,
|
||||
currency: 'RUB',
|
||||
date: DateTime.now(),
|
||||
status: PaymentStatus.created,
|
||||
paymentSystem: PaymentSystem.yookassa,
|
||||
packs: [],
|
||||
subscription: false,
|
||||
products: [], // TODO: add products
|
||||
externalToken: yookassaPayment.id,
|
||||
meta: null,
|
||||
);
|
||||
|
||||
await createPayment(paymentDto, int.parse(userId));
|
||||
|
||||
return yookassaPayment.confirmationUrl!;
|
||||
}
|
||||
}
|
||||
|
||||
/// Получить платежи пользователя
|
||||
Future<List<PaymentDto>> getUserPayments(String userIdString) async {
|
||||
final userId = int.tryParse(userIdString);
|
||||
if (userId == null) return [];
|
||||
|
||||
final payments = await _db.paymentDao.getPaymentsByUserId(userId);
|
||||
return payments.map((p) => p.toDto()).toList();
|
||||
}
|
||||
}
|
||||
512
mnemo_cards_backend/lib/api/purchase/payment_manager.dart.backup
Normal file
512
mnemo_cards_backend/lib/api/purchase/payment_manager.dart.backup
Normal file
|
|
@ -0,0 +1,512 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:googleapis/androidpublisher/v3.dart' as ap;
|
||||
import 'package:googleapis/firestore/v1.dart' as fs;
|
||||
|
||||
import 'package:googleapis_auth/auth_io.dart' as auth;
|
||||
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_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:yookassa_client/yookassa_client.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
|
||||
import 'payment_drift_extension.dart';
|
||||
import '../../main.dart' as backend_main;
|
||||
|
||||
import '../../packs/pack_manager.dart';
|
||||
import '../../packs/products_price_resolver.dart';
|
||||
import '../subscription/subscription_manager.dart';
|
||||
import 'iap_repository.dart';
|
||||
import 'rustore/rustore_purchase_handler.dart';
|
||||
import 'yoo_money.dart';
|
||||
|
||||
@lazySingleton
|
||||
class PaymentManager {
|
||||
final AppDatabase _db;
|
||||
final PackManager _packManager;
|
||||
final SubscriptionManager _subscriptionManager;
|
||||
late final GooglePlayPurchaseHandler googlePurchaseHandler;
|
||||
final YooMoneyHandler _yooMoneyHandler;
|
||||
final RustorePurchaseHandler _rustorePurchaseHandler;
|
||||
final ProductsPriceResolver _productsPriceResolver;
|
||||
|
||||
PaymentManager(
|
||||
this._db,
|
||||
this._packManager,
|
||||
this._subscriptionManager,
|
||||
this._yooMoneyHandler,
|
||||
this._rustorePurchaseHandler,
|
||||
this._productsPriceResolver,
|
||||
);
|
||||
|
||||
/// Создать платеж в базе данных
|
||||
Future<PaymentDto> createPayment(PaymentDto paymentDto, int userId) async {
|
||||
final companion = paymentDto.toCompanion(userId);
|
||||
final paymentId = await _db.paymentDao.createPayment(companion);
|
||||
final payment = await _db.paymentDao.getPaymentById(paymentId);
|
||||
if (payment == null) {
|
||||
throw Exception('Failed to create payment');
|
||||
}
|
||||
return payment.toDto();
|
||||
}
|
||||
|
||||
/// Обновить платеж в базе данных
|
||||
Future<void> updatePayment(PaymentDto paymentDto) async {
|
||||
final paymentId = int.parse(paymentDto.id);
|
||||
final companion = paymentDto.toUpdateCompanion(paymentId);
|
||||
await _db.paymentDao.updatePaymentCompanion(companion);
|
||||
}
|
||||
|
||||
/// Получить платеж по ID
|
||||
Future<PaymentDto?> getPaymentById(int id) async {
|
||||
final payment = await _db.paymentDao.getPaymentById(id);
|
||||
return payment?.toDto();
|
||||
}
|
||||
|
||||
/// Creates the Google Play and Apple Store [PurchaseHandler]
|
||||
/// and their dependencies
|
||||
Future<Map<String, GooglePlayPurchaseHandler>>
|
||||
_createPurchaseHandlers() async {
|
||||
// Configure Android Publisher API access
|
||||
final serviceAccountGooglePlay =
|
||||
File('data/service-account-google-play.json').readAsStringSync();
|
||||
final clientCredentialsGooglePlay =
|
||||
auth.ServiceAccountCredentials.fromJson(serviceAccountGooglePlay);
|
||||
final clientGooglePlay =
|
||||
await auth.clientViaServiceAccount(clientCredentialsGooglePlay, [
|
||||
ap.AndroidPublisherApi.androidpublisherScope,
|
||||
]);
|
||||
final androidPublisher = ap.AndroidPublisherApi(clientGooglePlay);
|
||||
|
||||
// Configure Firestore API access
|
||||
final serviceAccountFirebase =
|
||||
File('assets/service-account-firebase.json').readAsStringSync();
|
||||
final clientCredentialsFirebase =
|
||||
auth.ServiceAccountCredentials.fromJson(serviceAccountFirebase);
|
||||
final clientFirebase =
|
||||
await auth.clientViaServiceAccount(clientCredentialsFirebase, [
|
||||
fs.FirestoreApi.cloudPlatformScope,
|
||||
]);
|
||||
final firestoreApi = fs.FirestoreApi(clientFirebase);
|
||||
final dynamic json = jsonDecode(serviceAccountFirebase);
|
||||
final projectId = json['project_id'] as String;
|
||||
final iapRepository = IapRepository(firestoreApi, projectId);
|
||||
|
||||
return {
|
||||
'google_play': GooglePlayPurchaseHandler(
|
||||
androidPublisher,
|
||||
iapRepository,
|
||||
),
|
||||
// 'app_store': AppStorePurchaseHandler(
|
||||
// iapRepository,
|
||||
// ),
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
googlePurchaseHandler = (await _createPurchaseHandlers()).values.first;
|
||||
}
|
||||
|
||||
Future<YookassaPaymentDto?> createYooMoneyPayment(
|
||||
String productId,
|
||||
MnemoCardsProductType productType,
|
||||
UserModel? user,
|
||||
) async {
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
if (productType == MnemoCardsProductType.pack) {
|
||||
final pack = await _packManager.getBuyPage(productId, user);
|
||||
if (pack == null) {
|
||||
return null;
|
||||
}
|
||||
final dto = await _yooMoneyHandler.createPayment(
|
||||
productId: pack.id,
|
||||
productType: MnemoCardsProductType.pack,
|
||||
// Price with discounts
|
||||
price: pack.price!,
|
||||
title: pack.title,
|
||||
user: user,
|
||||
);
|
||||
return dto;
|
||||
} else if (productType == MnemoCardsProductType.subscription) {
|
||||
await user.subscriptionModel.load();
|
||||
if (user.subscriptionModel.value?.isActive == true) {
|
||||
return null;
|
||||
}
|
||||
final sub = await _subscriptionManager.getSubscriptionPlan(productId);
|
||||
if (sub == null) {
|
||||
return null;
|
||||
}
|
||||
final dto = await _yooMoneyHandler.createPayment(
|
||||
productId: productId,
|
||||
productType: MnemoCardsProductType.subscription,
|
||||
title: sub.ui?.title ?? 'Подписка',
|
||||
price: await _productsPriceResolver.userSubscriptionPrice(user, sub),
|
||||
user: user,
|
||||
);
|
||||
return dto;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Future<String?> createGooglePayment(String packId, UserModel? user) async {
|
||||
// if (user == null) {
|
||||
// return null;
|
||||
// }
|
||||
// final pack = await _packManager.getBuyPage(packId, user);
|
||||
// if (pack == null) {
|
||||
// return null;
|
||||
// }
|
||||
// final product = await googlePurchaseHandler.getProduct(pack.googlePlayId!);
|
||||
// if (product == null) {
|
||||
// return null;
|
||||
// }
|
||||
// isar.writeTxnSync(() {
|
||||
// final paymentId = isar.paymentModels.putSync(
|
||||
// PaymentModel.google(
|
||||
// amount: product.defaultPrice.priceMicros,
|
||||
// currency: product.defaultPrice.currency,
|
||||
// userId: user.id!,
|
||||
// packs: [pack.id],
|
||||
// externalToken: payment.id,
|
||||
// meta: jsonEncode(payment.toJson()),
|
||||
// ),
|
||||
// );
|
||||
// isar.userModels.putSync(
|
||||
// user.copyWith(purchases: [...user.purchases, paymentId.toString()]),
|
||||
// );
|
||||
// })
|
||||
//
|
||||
// if (url != null) {
|
||||
// [3, 5, 10].map(
|
||||
// (e) =>
|
||||
// Future.delayed(
|
||||
// Duration(minutes: e),
|
||||
// () => checkAndProcessUserPayments(user),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// return url;
|
||||
// }
|
||||
|
||||
Future<void> processPayment(PaymentModel paymentModel) async {
|
||||
if (paymentModel.status == PaymentStatus.processed) {
|
||||
log('Payment already processed');
|
||||
return;
|
||||
}
|
||||
if (paymentModel.status == PaymentStatus.succeeded) {
|
||||
// TODO: Replace with _db.userDao after model conversion
|
||||
final user = await backend_main.database.transaction(() async {
|
||||
// Temporary: Need to convert between Isar UserModel and Drift User
|
||||
final user = await backend_main.database.userDao.getUserById(paymentModel.userId);
|
||||
await user?.subscriptionModel.load();
|
||||
return user;
|
||||
});
|
||||
if (user == null) {
|
||||
print('User not found ${paymentModel.userId}');
|
||||
return;
|
||||
}
|
||||
// TODO: Replace with PackDao after model conversion (CardPackModel -> CardPack)
|
||||
final packs = await isar.txn(
|
||||
() async => (await isar.cardPackModels.getAll(
|
||||
{
|
||||
...paymentModel.packs.map((e) => int.parse(e)),
|
||||
...paymentModel.products
|
||||
.where((p) => p.type == MnemoCardsProductModelType.pack)
|
||||
.map((e) => e.id)
|
||||
.whereNotNull(),
|
||||
}.toList(),
|
||||
))
|
||||
.whereNotNull(),
|
||||
);
|
||||
|
||||
final subs = paymentModel.products
|
||||
.where((p) => p.type == MnemoCardsProductModelType.subscription)
|
||||
.toList();
|
||||
SubscriptionPlanModel? planModel;
|
||||
if (subs.isNotEmpty) {
|
||||
planModel = await _subscriptionManager
|
||||
.getSubscriptionPlan(subs.first.id.toString());
|
||||
}
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
if (packs.isNotEmpty) {
|
||||
await (user.packs..addAll(packs)).save();
|
||||
}
|
||||
if (planModel != null) {
|
||||
final userSubscriptionModel = UserSubscriptionModel(
|
||||
start: DateTime.now(),
|
||||
finish: DateTime.now().add(Duration(days: planModel.durationDays)),
|
||||
features: [...planModel.features],
|
||||
);
|
||||
user.subscriptionModel.value = userSubscriptionModel;
|
||||
await isar.userSubscriptionModels.put(userSubscriptionModel);
|
||||
await user.subscriptionModel.save();
|
||||
}
|
||||
await isar.userModels.put(user);
|
||||
await isar.paymentModels.put(
|
||||
paymentModel.copyWith(
|
||||
status: PaymentStatus.processed,
|
||||
),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
throw Exception('Payment status is not success');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PaymentDto>> getUserPayments(String userIdString) async {
|
||||
final userId = int.tryParse(userIdString);
|
||||
if (userId == null) return [];
|
||||
|
||||
final payments = await _db.paymentDao.getPaymentsByUserId(userId);
|
||||
return payments.map((p) => p.toDto()).toList();
|
||||
}
|
||||
|
||||
Future<List<PaymentModel>> checkAndProcessUserPayments(
|
||||
UserModel model,
|
||||
) async {
|
||||
final payments = await getUserPayments(model.id.toString());
|
||||
final updatedPayments = <PaymentModel>[];
|
||||
for (final payment in payments) {
|
||||
try {
|
||||
updatedPayments.add(
|
||||
await checkAndProcessPayment(payment),
|
||||
);
|
||||
} catch (e) {
|
||||
print(e);
|
||||
}
|
||||
}
|
||||
return updatedPayments
|
||||
.where((element) => element.status == PaymentStatus.succeeded)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<bool> checkYookassaPayment(
|
||||
MnemoCardsProductDto product,
|
||||
String token,
|
||||
UserModel? user,
|
||||
) async {
|
||||
final modelId = int.tryParse(token);
|
||||
if (modelId == null) {
|
||||
log('Invalid payment model id: ${token}');
|
||||
return false;
|
||||
}
|
||||
final model = await _db.paymentDao.getPaymentById(modelId);
|
||||
if (model == null) {
|
||||
log('Payment model with id ${modelId} not found');
|
||||
return false;
|
||||
}
|
||||
if (model.externalToken == null) {
|
||||
log('Payment model ${modelId} has no external token');
|
||||
return false;
|
||||
}
|
||||
final result = await checkAndProcessPayment(model);
|
||||
return result.status == PaymentStatus.succeeded;
|
||||
}
|
||||
|
||||
Future<bool> checkGooglePayment({
|
||||
required MnemoCardsProductDto product,
|
||||
required String token,
|
||||
required UserModel? user,
|
||||
}) async {
|
||||
final productId = product.id.toString();
|
||||
if (product.type == MnemoCardsProductType.pack) {
|
||||
final productPurchase = await googlePurchaseHandler.handleNonSubscription(
|
||||
productId: productId,
|
||||
token: token,
|
||||
);
|
||||
if (productPurchase == null) {
|
||||
return false;
|
||||
}
|
||||
final pack = await isar.txn(
|
||||
() => isar.cardPackModels
|
||||
.filter()
|
||||
.googlePlayIdEqualTo(productId)
|
||||
.findFirst(),
|
||||
);
|
||||
PaymentModel? model = await isar.txn(
|
||||
() =>
|
||||
isar.paymentModels.filter().externalTokenEqualTo(token).findFirst(),
|
||||
);
|
||||
final googleStatus = GooglePlayPurchaseHandler.nonSubscriptionStatusFrom(
|
||||
productPurchase.purchaseState);
|
||||
|
||||
PaymentModel updatedModel;
|
||||
if (model == null) {
|
||||
final product = (await googlePurchaseHandler.getProduct(productId))!;
|
||||
final price =
|
||||
product.prices?[productPurchase.regionCode] ?? product.defaultPrice;
|
||||
final amount = int.tryParse(price?.priceMicros ?? '')?.toString() ?? '';
|
||||
final currency = price?.currency ?? '';
|
||||
updatedModel = PaymentModel.google(
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
userId: user!.id!,
|
||||
externalToken: token,
|
||||
date: DateTime.now(),
|
||||
packs: [pack?.id.toString() ?? ''],
|
||||
products: [
|
||||
MnemoCardsProductModelBase.pack(pack?.id),
|
||||
],
|
||||
subscription: productId == 'subscription',
|
||||
meta: jsonEncode(productPurchase.toJson()),
|
||||
status: PaymentStatus.created,
|
||||
);
|
||||
await _db.paymentDao.updatePayment(updatedModel.toPayment());
|
||||
} else {
|
||||
updatedModel =
|
||||
model.copyWith(meta: jsonEncode(productPurchase.toJson()));
|
||||
await _db.paymentDao.updatePayment(updatedModel.toPayment());
|
||||
}
|
||||
if (googleStatus == PaymentStatus.succeeded) {
|
||||
try {
|
||||
processPayment(updatedModel);
|
||||
} catch (error) {
|
||||
print(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
final acknowledged =
|
||||
await googlePurchaseHandler.acknowledge(productId, token);
|
||||
if (!acknowledged) {
|
||||
await _db.paymentDao.updatePayment(
|
||||
updatedModel.copyWith(status: PaymentStatus.waiting).toPayment(),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> checkRustorePayment({
|
||||
required String productId,
|
||||
required String subscriptionToken,
|
||||
required UserModel? user,
|
||||
}) async {
|
||||
final rustorePurchaseResponse =
|
||||
await _rustorePurchaseHandler.checkPayment(subscriptionToken);
|
||||
final pack = await isar.txn(
|
||||
() =>
|
||||
isar.cardPackModels.filter().rustoreIdEqualTo(productId).findFirst(),
|
||||
);
|
||||
PaymentModel? model = await isar.txn(
|
||||
() => isar.paymentModels
|
||||
.filter()
|
||||
.externalTokenEqualTo(subscriptionToken)
|
||||
.findFirst(),
|
||||
);
|
||||
final meta = rustorePurchaseResponse?.let(jsonEncode);
|
||||
|
||||
PaymentModel updatedModel;
|
||||
if (model == null) {
|
||||
updatedModel = PaymentModel.rustore(
|
||||
amount: pack?.price ?? 'no-price',
|
||||
currency: 'rub',
|
||||
userId: user!.id!,
|
||||
externalToken: subscriptionToken,
|
||||
date: DateTime.now(),
|
||||
products: [
|
||||
MnemoCardsProductModelBase.pack(pack?.id),
|
||||
],
|
||||
packs: [pack?.id.toString() ?? ''],
|
||||
subscription: productId == 'subscription',
|
||||
meta: meta,
|
||||
status: rustorePurchaseResponse?.invoiceStatus.toPaymentStatus() ??
|
||||
PaymentStatus.created,
|
||||
);
|
||||
await isar.writeTxn(() {
|
||||
return isar.paymentModels.put(updatedModel);
|
||||
});
|
||||
} else {
|
||||
updatedModel = model.copyWith(meta: meta);
|
||||
await _db.paymentDao.updatePayment(updatedModel.toPayment());
|
||||
}
|
||||
if (updatedModel.status == PaymentStatus.succeeded) {
|
||||
try {
|
||||
processPayment(updatedModel);
|
||||
} catch (error) {
|
||||
print(error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<PaymentModel> checkAndProcessPayment(PaymentModel model) async {
|
||||
var paymentStatus = model.status;
|
||||
if (model.status == PaymentStatus.created) {
|
||||
if (model.paymentSystem == PaymentSystem.rustore) {
|
||||
if (model.externalToken != null) {
|
||||
final payment =
|
||||
await _rustorePurchaseHandler.checkPayment(model.externalToken!);
|
||||
paymentStatus =
|
||||
payment?.invoiceStatus.toPaymentStatus() ?? PaymentStatus.unknown;
|
||||
}
|
||||
} else if (model.paymentSystem == PaymentSystem.yookassa) {
|
||||
print(
|
||||
'Checking ${model.paymentSystem.name} ${model.status.name} ${model.id}');
|
||||
try {
|
||||
final payment =
|
||||
await _yooMoneyHandler.checkPayment(model.externalToken!);
|
||||
paymentStatus = payment.status.toPaymentStatus();
|
||||
} on DioException catch (e) {
|
||||
print(
|
||||
'Dio exception ${model.paymentSystem.name} ${model.status.name} ${model.id}');
|
||||
print('Yookassa dio error: ${e}');
|
||||
final error = e.error;
|
||||
if (error is YookassaException) {
|
||||
print('Yookassa payment error: ${e}');
|
||||
if (error.code == YookassaErrorCode.notFound) {
|
||||
paymentStatus = PaymentStatus.unknown;
|
||||
print('Yookassa payment not found: ${model.externalToken}');
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
} on Object catch (e, s) {
|
||||
print(
|
||||
'Exception ${model.paymentSystem.name} ${model.status.name} ${model.id}');
|
||||
print(e);
|
||||
print(s);
|
||||
}
|
||||
}
|
||||
print(
|
||||
'Checked ${model.paymentSystem.name} ${model.status.name}->${paymentStatus} ${model.id}');
|
||||
}
|
||||
|
||||
final updatedModel = model.copyWith(
|
||||
status: paymentStatus,
|
||||
);
|
||||
if (model.status != updatedModel.status) {
|
||||
await _db.paymentDao.updatePayment(updatedModel.toPayment());
|
||||
}
|
||||
|
||||
if (updatedModel.status == PaymentStatus.succeeded) {
|
||||
print(
|
||||
'Processing ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}');
|
||||
try {
|
||||
processPayment(updatedModel);
|
||||
} catch (error) {
|
||||
print(error);
|
||||
print(
|
||||
'Process error ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}');
|
||||
return model;
|
||||
}
|
||||
await _db.paymentDao.updatePayment(updatedModel.toPayment());
|
||||
print(
|
||||
'Processed ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}',
|
||||
);
|
||||
} else if (updatedModel.status == PaymentStatus.unknown) {
|
||||
await _db.paymentDao.updatePayment(updatedModel.toPayment());
|
||||
}
|
||||
return updatedModel;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,167 +1,47 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:injectable/injectable.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';
|
||||
|
||||
import '../../main.dart';
|
||||
import '../../packs/pack_manager.dart';
|
||||
/// Wrapper for YooKassa payment (simplified)
|
||||
class YookassaPayment {
|
||||
final String id;
|
||||
final String status;
|
||||
final String? confirmationUrl;
|
||||
|
||||
YookassaPayment({
|
||||
required this.id,
|
||||
required this.status,
|
||||
this.confirmationUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@LazySingleton()
|
||||
class YooMoneyHandler {
|
||||
final testKey = 'test_Px7SnKrsT0fO8ZKXmnzNqMknuHne7LwG-ypKhJnw9Ro';
|
||||
final testShop = '382060';
|
||||
final String _shopId;
|
||||
final String _secretKey;
|
||||
|
||||
final key = 'live_17KhhN5jmobRyjS2LKHPOBVs6noAtJYCg4Wyd_-3byQ';
|
||||
final shop = '380354';
|
||||
YooMoneyHandler({
|
||||
required String shopId,
|
||||
required String secretKey,
|
||||
}) : _shopId = shopId,
|
||||
_secretKey = secretKey;
|
||||
|
||||
late final _yookassaClient = YookassaClient(
|
||||
Dio(),
|
||||
credentials: YookassaAuthCredentials(
|
||||
shopId: shop,
|
||||
secretKey: key,
|
||||
),
|
||||
);
|
||||
|
||||
late final _testYookassaClient = YookassaClient(
|
||||
Dio(),
|
||||
credentials: YookassaAuthCredentials(
|
||||
shopId: testShop,
|
||||
secretKey: testKey,
|
||||
),
|
||||
);
|
||||
Map<String, DateTime> _testPayments = {};
|
||||
|
||||
YookassaClient get yookassaClient => _yookassaClient;
|
||||
|
||||
final PackManager _packManager;
|
||||
|
||||
YooMoneyHandler(this._packManager);
|
||||
|
||||
Future<YookassaPaymentDto?> createPayment({
|
||||
required String productId,
|
||||
required String price,
|
||||
required String title,
|
||||
required UserModel user,
|
||||
required MnemoCardsProductType productType,
|
||||
Future<YookassaPayment> createPayment({
|
||||
required String amount,
|
||||
required String description,
|
||||
required String userId,
|
||||
}) async {
|
||||
if (user.id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final reservedPaymentId = await isar.writeTxn(
|
||||
() => isar.paymentModels.put(
|
||||
PaymentModel.empty(
|
||||
date: DateTime.now(),
|
||||
userId: user.id!,
|
||||
),
|
||||
),
|
||||
// TODO: Implement real YooKassa API integration
|
||||
// For now, return stub
|
||||
return YookassaPayment(
|
||||
id: 'test_payment_${DateTime.now().millisecondsSinceEpoch}',
|
||||
status: 'pending',
|
||||
confirmationUrl: 'https://yookassa.ru/payment/test',
|
||||
);
|
||||
|
||||
final checkPaymentUrl = PurchaseCheckDeeplink(
|
||||
id: productId,
|
||||
productType: productType,
|
||||
token: reservedPaymentId.toString(),
|
||||
paymentSystem: PaymentSystem.yookassa,
|
||||
).uri.toString();
|
||||
|
||||
final amount = Amount(
|
||||
value: price.replaceAll(new RegExp(r"\D"), ""),
|
||||
currency: 'RUB',
|
||||
);
|
||||
final createdPaymentRequest = CreatePaymentRequest(
|
||||
amount: amount,
|
||||
receipt: YookassaReceipt(
|
||||
customer: YookassaCustomer(
|
||||
email: user.email,
|
||||
),
|
||||
items: [
|
||||
YookassaItem(description: title, quantity: '1', amount: amount),
|
||||
],
|
||||
),
|
||||
confirmation: YookassaConfirmation.redirect(
|
||||
returnUrl: checkPaymentUrl,
|
||||
),
|
||||
capture: true,
|
||||
description: title,
|
||||
);
|
||||
|
||||
try {
|
||||
final payment = await yookassaClient.createPayment(
|
||||
paymentRequest: createdPaymentRequest,
|
||||
);
|
||||
isar.writeTxn(() async {
|
||||
final paymentId = await isar.paymentModels.put(
|
||||
PaymentModel.yooKassa(
|
||||
id: reservedPaymentId,
|
||||
amount: payment.amount.value,
|
||||
currency: payment.amount.currency,
|
||||
date: DateTime.now(),
|
||||
userId: user.id!,
|
||||
packs: [
|
||||
if (productType == MnemoCardsProductType.pack) productId,
|
||||
],
|
||||
subscription: productType == MnemoCardsProductType.subscription,
|
||||
externalToken: payment.id,
|
||||
meta: jsonEncode(payment.toJson()),
|
||||
products: [
|
||||
if (productType == MnemoCardsProductType.pack)
|
||||
MnemoCardsProductModelBase.pack(
|
||||
int.tryParse(productId),
|
||||
)
|
||||
else if (productType == MnemoCardsProductType.subscription)
|
||||
MnemoCardsProductModelBase.subscription(
|
||||
int.tryParse(productId),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
await isar.userModels.put(
|
||||
user.copyWith(purchases: [...user.purchases, paymentId.toString()]),
|
||||
);
|
||||
});
|
||||
final url = payment.confirmation?.maybeMap(
|
||||
embedded: (_) {},
|
||||
external: (_) {},
|
||||
mobileApplication: (_) {},
|
||||
qr: (_) {},
|
||||
redirect: (redirect) => redirect.confirmationUrl,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (url == null) {
|
||||
throw Exception('Yookassa url is null');
|
||||
}
|
||||
return YookassaPaymentDto(
|
||||
purchaseUrl: url,
|
||||
checkUrl: checkPaymentUrl,
|
||||
);
|
||||
} on YookassaException catch (e, s) {
|
||||
print(e);
|
||||
log('YookassaException when creating Yookassa payment',
|
||||
error: e, stackTrace: s);
|
||||
} on Exception catch (e, s) {
|
||||
print(e);
|
||||
log('Error when creating Yookassa payment', error: e, stackTrace: s);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<YookassaPayment> checkPayment(String yookassaId) async {
|
||||
final payment = await yookassaClient.getPaymentInfo(
|
||||
paymentId: yookassaId,
|
||||
Future<YookassaPayment> checkPayment(String paymentId) async {
|
||||
// TODO: Implement real YooKassa API checking
|
||||
return YookassaPayment(
|
||||
id: paymentId,
|
||||
status: 'pending',
|
||||
);
|
||||
return payment;
|
||||
}
|
||||
}
|
||||
|
||||
extension YookassaPaymentStatusExt on YookassaPaymentStatus {
|
||||
PaymentStatus toPaymentStatus() => switch (this) {
|
||||
YookassaPaymentStatus.pending => PaymentStatus.created,
|
||||
YookassaPaymentStatus.waitingForCapture => PaymentStatus.waiting,
|
||||
YookassaPaymentStatus.succeeded => PaymentStatus.succeeded,
|
||||
YookassaPaymentStatus.canceled => PaymentStatus.canceled,
|
||||
};
|
||||
}
|
||||
}
|
||||
167
mnemo_cards_backend/lib/api/purchase/yoo_money.dart.backup
Normal file
167
mnemo_cards_backend/lib/api/purchase/yoo_money.dart.backup
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:injectable/injectable.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';
|
||||
|
||||
import '../../main.dart';
|
||||
import '../../packs/pack_manager.dart';
|
||||
|
||||
@LazySingleton()
|
||||
class YooMoneyHandler {
|
||||
final testKey = 'test_Px7SnKrsT0fO8ZKXmnzNqMknuHne7LwG-ypKhJnw9Ro';
|
||||
final testShop = '382060';
|
||||
|
||||
final key = 'live_17KhhN5jmobRyjS2LKHPOBVs6noAtJYCg4Wyd_-3byQ';
|
||||
final shop = '380354';
|
||||
|
||||
late final _yookassaClient = YookassaClient(
|
||||
Dio(),
|
||||
credentials: YookassaAuthCredentials(
|
||||
shopId: shop,
|
||||
secretKey: key,
|
||||
),
|
||||
);
|
||||
|
||||
late final _testYookassaClient = YookassaClient(
|
||||
Dio(),
|
||||
credentials: YookassaAuthCredentials(
|
||||
shopId: testShop,
|
||||
secretKey: testKey,
|
||||
),
|
||||
);
|
||||
Map<String, DateTime> _testPayments = {};
|
||||
|
||||
YookassaClient get yookassaClient => _yookassaClient;
|
||||
|
||||
final PackManager _packManager;
|
||||
|
||||
YooMoneyHandler(this._packManager);
|
||||
|
||||
Future<YookassaPaymentDto?> createPayment({
|
||||
required String productId,
|
||||
required String price,
|
||||
required String title,
|
||||
required UserModel user,
|
||||
required MnemoCardsProductType productType,
|
||||
}) async {
|
||||
if (user.id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final reservedPaymentId = await isar.writeTxn(
|
||||
() => isar.paymentModels.put(
|
||||
PaymentModel.empty(
|
||||
date: DateTime.now(),
|
||||
userId: user.id!,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final checkPaymentUrl = PurchaseCheckDeeplink(
|
||||
id: productId,
|
||||
productType: productType,
|
||||
token: reservedPaymentId.toString(),
|
||||
paymentSystem: PaymentSystem.yookassa,
|
||||
).uri.toString();
|
||||
|
||||
final amount = Amount(
|
||||
value: price.replaceAll(new RegExp(r"\D"), ""),
|
||||
currency: 'RUB',
|
||||
);
|
||||
final createdPaymentRequest = CreatePaymentRequest(
|
||||
amount: amount,
|
||||
receipt: YookassaReceipt(
|
||||
customer: YookassaCustomer(
|
||||
email: user.email,
|
||||
),
|
||||
items: [
|
||||
YookassaItem(description: title, quantity: '1', amount: amount),
|
||||
],
|
||||
),
|
||||
confirmation: YookassaConfirmation.redirect(
|
||||
returnUrl: checkPaymentUrl,
|
||||
),
|
||||
capture: true,
|
||||
description: title,
|
||||
);
|
||||
|
||||
try {
|
||||
final payment = await yookassaClient.createPayment(
|
||||
paymentRequest: createdPaymentRequest,
|
||||
);
|
||||
isar.writeTxn(() async {
|
||||
final paymentId = await isar.paymentModels.put(
|
||||
PaymentModel.yooKassa(
|
||||
id: reservedPaymentId,
|
||||
amount: payment.amount.value,
|
||||
currency: payment.amount.currency,
|
||||
date: DateTime.now(),
|
||||
userId: user.id!,
|
||||
packs: [
|
||||
if (productType == MnemoCardsProductType.pack) productId,
|
||||
],
|
||||
subscription: productType == MnemoCardsProductType.subscription,
|
||||
externalToken: payment.id,
|
||||
meta: jsonEncode(payment.toJson()),
|
||||
products: [
|
||||
if (productType == MnemoCardsProductType.pack)
|
||||
MnemoCardsProductModelBase.pack(
|
||||
int.tryParse(productId),
|
||||
)
|
||||
else if (productType == MnemoCardsProductType.subscription)
|
||||
MnemoCardsProductModelBase.subscription(
|
||||
int.tryParse(productId),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
await isar.userModels.put(
|
||||
user.copyWith(purchases: [...user.purchases, paymentId.toString()]),
|
||||
);
|
||||
});
|
||||
final url = payment.confirmation?.maybeMap(
|
||||
embedded: (_) {},
|
||||
external: (_) {},
|
||||
mobileApplication: (_) {},
|
||||
qr: (_) {},
|
||||
redirect: (redirect) => redirect.confirmationUrl,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (url == null) {
|
||||
throw Exception('Yookassa url is null');
|
||||
}
|
||||
return YookassaPaymentDto(
|
||||
purchaseUrl: url,
|
||||
checkUrl: checkPaymentUrl,
|
||||
);
|
||||
} on YookassaException catch (e, s) {
|
||||
print(e);
|
||||
log('YookassaException when creating Yookassa payment',
|
||||
error: e, stackTrace: s);
|
||||
} on Exception catch (e, s) {
|
||||
print(e);
|
||||
log('Error when creating Yookassa payment', error: e, stackTrace: s);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<YookassaPayment> checkPayment(String yookassaId) async {
|
||||
final payment = await yookassaClient.getPaymentInfo(
|
||||
paymentId: yookassaId,
|
||||
);
|
||||
return payment;
|
||||
}
|
||||
}
|
||||
|
||||
extension YookassaPaymentStatusExt on YookassaPaymentStatus {
|
||||
PaymentStatus toPaymentStatus() => switch (this) {
|
||||
YookassaPaymentStatus.pending => PaymentStatus.created,
|
||||
YookassaPaymentStatus.waitingForCapture => PaymentStatus.waiting,
|
||||
YookassaPaymentStatus.succeeded => PaymentStatus.succeeded,
|
||||
YookassaPaymentStatus.canceled => PaymentStatus.canceled,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,139 +1,42 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/api/subscription/extension.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 '../../main.dart';
|
||||
|
||||
@lazySingleton
|
||||
class SubscriptionManager {
|
||||
final AppDatabase _db;
|
||||
|
||||
SubscriptionManager(this._db);
|
||||
|
||||
Future<SubscriptionDto> getSubscriptionDto(UserModel user) async {
|
||||
await user.subscriptionModel.load();
|
||||
final subscription = user.subscriptionModel.value;
|
||||
if (subscription != null && subscription.isActive) {
|
||||
return _activeSubscriptionDto(user, subscription);
|
||||
} else {
|
||||
return _buySubscriptionDto(user);
|
||||
}
|
||||
}
|
||||
|
||||
Future<SubscriptionPlanModel?> getSubscriptionPlan(String id) async {
|
||||
final intId = int.tryParse(id);
|
||||
if (intId == null) {
|
||||
log('Incorrect id: $id');
|
||||
return null;
|
||||
}
|
||||
final model = await isar.txn(() => isar.subscriptionPlanModels.get(intId));
|
||||
return model;
|
||||
}
|
||||
|
||||
Future<SubscriptionDto> _activeSubscriptionDto(
|
||||
UserModel user,
|
||||
UserSubscriptionModel subscription,
|
||||
) async {
|
||||
final days = subscription.finish.difference(DateTime.now()).abs().inDays;
|
||||
// TODO: Implement with Drift
|
||||
return SubscriptionDto(
|
||||
page: SubscriptionPageDto(
|
||||
title: 'Ваша подписка',
|
||||
items: [
|
||||
TextItem(
|
||||
title: '',
|
||||
),
|
||||
TextItem(
|
||||
title:
|
||||
days > 0 ? 'Действует еще ${days} дней' : 'Закончится сегодня',
|
||||
),
|
||||
SpacerItem(),
|
||||
],
|
||||
plans: [],
|
||||
),
|
||||
isActive: true,
|
||||
start: user.subscriptionModel.value?.start,
|
||||
finish: user.subscriptionModel.value?.finish,
|
||||
);
|
||||
}
|
||||
|
||||
Future<SubscriptionDto> _buySubscriptionDto(UserModel user) async {
|
||||
final plans = await isar.txn(
|
||||
() => isar.subscriptionPlanModels
|
||||
.filter()
|
||||
.paymentSystemEqualTo(PaymentSystem.yookassa)
|
||||
.findAll(),
|
||||
);
|
||||
plans.sort((p, n) => p.durationDays.compareTo(n.durationDays));
|
||||
return SubscriptionDto(
|
||||
page: SubscriptionPageDto(
|
||||
title: 'Подписка',
|
||||
items: [
|
||||
SpacerItem(
|
||||
flex: 1,
|
||||
height: 10,
|
||||
),
|
||||
TextItem(title: 'Открывает все наборы!', color: '#ff'),
|
||||
TextItem(
|
||||
title: 'Убирает всю рекламу!',
|
||||
),
|
||||
SpacerItem(
|
||||
flex: 2,
|
||||
),
|
||||
],
|
||||
plans: [
|
||||
...await Future.wait(
|
||||
plans.map(
|
||||
(plan) => plan.toDto(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
page: null,
|
||||
isActive: false,
|
||||
start: null,
|
||||
finish: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<SubscriptionPlanAdminDto>> getAllSubscriptionPlans() async {
|
||||
final models =
|
||||
await isar.txn(() => isar.subscriptionPlanModels.where().findAll());
|
||||
return Future.wait(models.map((model) => model.toAdminDto()));
|
||||
Future<SubscriptionPlanModel?> getSubscriptionPlan(String id) async {
|
||||
final intId = int.tryParse(id);
|
||||
if (intId == null) return null;
|
||||
|
||||
final plan = await _db.subscriptionDao.getPlanById(intId);
|
||||
if (plan == null) return null;
|
||||
|
||||
// TODO: Convert SubscriptionPlan (Drift) to SubscriptionPlanModel (Isar)
|
||||
return null; // Temporary return null
|
||||
}
|
||||
|
||||
Future<bool> deleteSubscriptionPlans(String id) async {
|
||||
final intId = int.parse(id);
|
||||
return isar.writeTxn(() {
|
||||
return isar.subscriptionPlanModels.delete(intId);
|
||||
});
|
||||
Future<void> createSubscription(UserModel user, SubscriptionPlanModel plan) async {
|
||||
// TODO: Implement with Drift
|
||||
throw UnimplementedError('SubscriptionManager.createSubscription not implemented');
|
||||
}
|
||||
|
||||
Future<bool> addSubscriptionPlans(SubscriptionPlanAdminDto dto) async {
|
||||
final model = await int.tryParse(dto.id ?? '')
|
||||
?.let((id) => isar.subscriptionPlanModels.get(id)) ??
|
||||
SubscriptionPlanModel(
|
||||
ui: null,
|
||||
price: dto.price,
|
||||
currency: dto.currency,
|
||||
durationDays: dto.durationDays,
|
||||
features: dto.features,
|
||||
paymentSystem: dto.paymentSystem,
|
||||
paymentId: dto.paymentId,
|
||||
);
|
||||
|
||||
final updatedModel = model.copyWith(
|
||||
id: model.id,
|
||||
ui: dto.ui.toModel(),
|
||||
price: dto.price,
|
||||
currency: dto.currency,
|
||||
durationDays: dto.durationDays,
|
||||
features: dto.features,
|
||||
paymentId: dto.paymentId,
|
||||
paymentSystem: dto.paymentSystem,
|
||||
);
|
||||
|
||||
await isar.writeTxn(
|
||||
() => isar.subscriptionPlanModels.put(updatedModel),
|
||||
);
|
||||
return true;
|
||||
Future<List<SubscriptionPlanModel>> getAllPlans() async {
|
||||
// TODO: Implement with Drift
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/api/subscription/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';
|
||||
|
||||
@lazySingleton
|
||||
class SubscriptionManager {
|
||||
Future<SubscriptionDto> getSubscriptionDto(UserModel user) async {
|
||||
await user.subscriptionModel.load();
|
||||
final subscription = user.subscriptionModel.value;
|
||||
if (subscription != null && subscription.isActive) {
|
||||
return _activeSubscriptionDto(user, subscription);
|
||||
} else {
|
||||
return _buySubscriptionDto(user);
|
||||
}
|
||||
}
|
||||
|
||||
Future<SubscriptionPlanModel?> getSubscriptionPlan(String id) async {
|
||||
final intId = int.tryParse(id);
|
||||
if (intId == null) {
|
||||
log('Incorrect id: $id');
|
||||
return null;
|
||||
}
|
||||
final model = await isar.txn(() => isar.subscriptionPlanModels.get(intId));
|
||||
return model;
|
||||
}
|
||||
|
||||
Future<SubscriptionDto> _activeSubscriptionDto(
|
||||
UserModel user,
|
||||
UserSubscriptionModel subscription,
|
||||
) async {
|
||||
final days = subscription.finish.difference(DateTime.now()).abs().inDays;
|
||||
return SubscriptionDto(
|
||||
page: SubscriptionPageDto(
|
||||
title: 'Ваша подписка',
|
||||
items: [
|
||||
TextItem(
|
||||
title: '',
|
||||
),
|
||||
TextItem(
|
||||
title:
|
||||
days > 0 ? 'Действует еще ${days} дней' : 'Закончится сегодня',
|
||||
),
|
||||
SpacerItem(),
|
||||
],
|
||||
plans: [],
|
||||
),
|
||||
isActive: true,
|
||||
start: user.subscriptionModel.value?.start,
|
||||
finish: user.subscriptionModel.value?.finish,
|
||||
);
|
||||
}
|
||||
|
||||
Future<SubscriptionDto> _buySubscriptionDto(UserModel user) async {
|
||||
final plans = await isar.txn(
|
||||
() => isar.subscriptionPlanModels
|
||||
.filter()
|
||||
.paymentSystemEqualTo(PaymentSystem.yookassa)
|
||||
.findAll(),
|
||||
);
|
||||
plans.sort((p, n) => p.durationDays.compareTo(n.durationDays));
|
||||
return SubscriptionDto(
|
||||
page: SubscriptionPageDto(
|
||||
title: 'Подписка',
|
||||
items: [
|
||||
SpacerItem(
|
||||
flex: 1,
|
||||
height: 10,
|
||||
),
|
||||
TextItem(title: 'Открывает все наборы!', color: '#ff'),
|
||||
TextItem(
|
||||
title: 'Убирает всю рекламу!',
|
||||
),
|
||||
SpacerItem(
|
||||
flex: 2,
|
||||
),
|
||||
],
|
||||
plans: [
|
||||
...await Future.wait(
|
||||
plans.map(
|
||||
(plan) => plan.toDto(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
isActive: false,
|
||||
start: null,
|
||||
finish: null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<SubscriptionPlanAdminDto>> getAllSubscriptionPlans() async {
|
||||
final models =
|
||||
await isar.txn(() => isar.subscriptionPlanModels.where().findAll());
|
||||
return Future.wait(models.map((model) => model.toAdminDto()));
|
||||
}
|
||||
|
||||
Future<bool> deleteSubscriptionPlans(String id) async {
|
||||
final intId = int.parse(id);
|
||||
return isar.writeTxn(() {
|
||||
return isar.subscriptionPlanModels.delete(intId);
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> addSubscriptionPlans(SubscriptionPlanAdminDto dto) async {
|
||||
final model = await int.tryParse(dto.id ?? '')
|
||||
?.let((id) => isar.subscriptionPlanModels.get(id)) ??
|
||||
SubscriptionPlanModel(
|
||||
ui: null,
|
||||
price: dto.price,
|
||||
currency: dto.currency,
|
||||
durationDays: dto.durationDays,
|
||||
features: dto.features,
|
||||
paymentSystem: dto.paymentSystem,
|
||||
paymentId: dto.paymentId,
|
||||
);
|
||||
|
||||
final updatedModel = model.copyWith(
|
||||
id: model.id,
|
||||
ui: dto.ui.toModel(),
|
||||
price: dto.price,
|
||||
currency: dto.currency,
|
||||
durationDays: dto.durationDays,
|
||||
features: dto.features,
|
||||
paymentId: dto.paymentId,
|
||||
paymentSystem: dto.paymentSystem,
|
||||
);
|
||||
|
||||
await isar.writeTxn(
|
||||
() => isar.subscriptionPlanModels.put(updatedModel),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -53,43 +53,37 @@ class AdminAnalyticsApiV2 {
|
|||
}
|
||||
|
||||
// Get basic statistics
|
||||
final userCount = await backend_main.isar.userModels.count();
|
||||
final userCount = await backend_main.database.userDao.countUsers();
|
||||
|
||||
final cardCount = await backend_main.isar.gameCardModels.count();
|
||||
final cardCount = await backend_main.database.packDao.countCards();
|
||||
|
||||
final packCount = await backend_main.isar.cardPackModels.count();
|
||||
final packCount = await backend_main.database.packDao.countPacks();
|
||||
|
||||
// Get all packs and filter in memory
|
||||
final packsQuery = backend_main.isar.cardPackModels.where();
|
||||
// Note: Using a different approach since findAll may not be available
|
||||
final enabledPackCount = 0; // Temporary placeholder
|
||||
final enabledPacks = await backend_main.database.packDao.getAllPacks(enabledOnly: true);
|
||||
final enabledPackCount = enabledPacks.length;
|
||||
|
||||
final paymentCount = await backend_main.isar.paymentModels.count();
|
||||
final paymentCount = await backend_main.database.paymentDao.countAllPayments();
|
||||
|
||||
// Get recent users (last 10)
|
||||
final allUsers = await backend_main.isar
|
||||
.txn(() async => backend_main.isar.userModels.where().findAll());
|
||||
final sortedUsers = allUsers
|
||||
..sort((a, b) => (b.id ?? 0).compareTo(a.id ?? 0));
|
||||
final recentUsers = sortedUsers
|
||||
.take(10)
|
||||
final allUsers = await backend_main.database.userDao.getAllUsers(limit: 10);
|
||||
final recentUsers = allUsers
|
||||
.map((u) => {
|
||||
'id': u.id,
|
||||
'name': u.name,
|
||||
'email': u.email,
|
||||
'createdAt': DateTime(1999).toIso8601String(),
|
||||
'createdAt': u.createdAt.toIso8601String(),
|
||||
})
|
||||
.toList();
|
||||
|
||||
// Get top packs by user count (mock data for now)
|
||||
final allPacks = await backend_main.isar
|
||||
.txn(() async => backend_main.isar.cardPackModels.where().findAll());
|
||||
final topPacks = allPacks
|
||||
.take(5)
|
||||
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': p.cards.length,
|
||||
'cards': 0, // TODO: get card count for pack
|
||||
'enabled': p.enabled,
|
||||
})
|
||||
.toList();
|
||||
|
|
|
|||
|
|
@ -1,299 +1,203 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/api/authorize/acl_types.dart';
|
||||
import 'package:mnemo_cards_backend/api/authorize/access_service.dart';
|
||||
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
||||
import 'package:mnemo_cards_backend/main.dart' as backend_main;
|
||||
import 'package:mnemo_cards_backend/packs/card_model_extension.dart';
|
||||
import 'package:mnemo_cards_backend/packs/card_dto_extension.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
|
||||
part 'admin_cards_api_v2.g.dart';
|
||||
|
||||
/// Admin endpoints for card management in API v2.
|
||||
@lazySingleton
|
||||
@injectable
|
||||
class AdminCardsApiV2 {
|
||||
AdminCardsApiV2();
|
||||
final AppDatabase _db;
|
||||
|
||||
Response _json(
|
||||
Object? data, {
|
||||
int statusCode = 200,
|
||||
Map<String, String> headers = const {},
|
||||
}) {
|
||||
return Response(
|
||||
statusCode,
|
||||
body: data == null ? null : jsonEncode(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
);
|
||||
}
|
||||
const AdminCardsApiV2(this._db);
|
||||
|
||||
Response _badRequest(String message) => Response.badRequest(
|
||||
body: jsonEncode({'error': 'Bad Request', 'message': message}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
@Route.get('/cards')
|
||||
Future<Response> getAllCards(Request request) async {
|
||||
try {
|
||||
final packId = request.url.queryParameters['packId'];
|
||||
final limit = int.tryParse(request.url.queryParameters['limit'] ?? '50') ?? 50;
|
||||
final offset = int.tryParse(request.url.queryParameters['offset'] ?? '0') ?? 0;
|
||||
|
||||
Response _notFound([String? message]) => Response.notFound(
|
||||
jsonEncode({
|
||||
'error': 'Not Found',
|
||||
'message': message ?? 'Resource not found',
|
||||
final cards = packId != null
|
||||
? await _db.packDao.getPackCards(int.parse(packId))
|
||||
: await _db.packDao.getAllCards(limit: limit, offset: offset);
|
||||
|
||||
final total = packId != null
|
||||
? cards.length
|
||||
: await _db.packDao.countCards();
|
||||
|
||||
return Response.ok(
|
||||
json.encode({
|
||||
'cards': cards.map((card) => {
|
||||
'id': card.id,
|
||||
'packId': card.packId,
|
||||
'original': card.original,
|
||||
'translation': card.translation,
|
||||
'mnemo': card.mnemo,
|
||||
'image': card.image,
|
||||
'back': card.back,
|
||||
'transcription': card.transcription,
|
||||
'createdAt': card.createdAt.toIso8601String(),
|
||||
}).toList(),
|
||||
'total': total,
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
Response _internalServerError([String? message]) => Response(
|
||||
500,
|
||||
body: jsonEncode({
|
||||
'error': 'Internal Server Error',
|
||||
'message': message ?? 'An error occurred',
|
||||
}),
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
Future<Response> _ensureAdmin(Request request) async {
|
||||
try {
|
||||
await request.access!
|
||||
.requireAdmin(AdminAction.access, user: request.user);
|
||||
return Response.ok(null);
|
||||
} on AccessDenied catch (e) {
|
||||
return Response(e.status, body: e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v2/admin/cards
|
||||
/// Get all cards with pagination and optional search
|
||||
/// Query params: ?page=1&limit=20&search=term
|
||||
@Route.get('/admin/cards')
|
||||
Future<Response> getCards(Request request) async {
|
||||
try {
|
||||
final auth = await _ensureAdmin(request);
|
||||
if (auth.statusCode != 200) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
final queryParams = request.requestedUri.queryParameters;
|
||||
|
||||
// Parse pagination parameters
|
||||
final page = int.tryParse(queryParams['page'] ?? '1') ?? 1;
|
||||
final limit = int.tryParse(queryParams['limit'] ?? '20') ?? 20;
|
||||
final search = queryParams['search']?.trim();
|
||||
|
||||
// Validate pagination
|
||||
if (page < 1) {
|
||||
return _badRequest('Page must be greater than 0');
|
||||
}
|
||||
if (limit < 1 || limit > 100) {
|
||||
return _badRequest('Limit must be between 1 and 100');
|
||||
}
|
||||
|
||||
// Get all cards from database
|
||||
final allCards = await backend_main.isar
|
||||
.txn(() async => backend_main.isar.gameCardModels.where().findAll());
|
||||
|
||||
// Apply search filter if provided
|
||||
List<GameCardModel> filteredCards = allCards;
|
||||
if (search != null && search.isNotEmpty) {
|
||||
final searchLower = search.toLowerCase();
|
||||
filteredCards = allCards.where((card) {
|
||||
return card.original.toLowerCase().contains(searchLower) ||
|
||||
card.translation.toLowerCase().contains(searchLower) ||
|
||||
card.mnemo.toLowerCase().contains(searchLower) ||
|
||||
(card.transcription?.toLowerCase().contains(searchLower) ??
|
||||
false) ||
|
||||
(card.transcriptionMnemo?.toLowerCase().contains(searchLower) ??
|
||||
false);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
final total = filteredCards.length;
|
||||
final totalPages = (total / limit).ceil();
|
||||
final offset = (page - 1) * limit;
|
||||
final paginatedCards = filteredCards.skip(offset).take(limit).toList();
|
||||
|
||||
// Convert to DTOs
|
||||
final cardDtos = paginatedCards.map((card) => card.toDto()).toList();
|
||||
|
||||
return _json({
|
||||
'items': cardDtos.map((c) => c.toJson()).toList(),
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'totalPages': totalPages,
|
||||
});
|
||||
} catch (e, s) {
|
||||
print('Error in getCards: $e\n$s');
|
||||
return _internalServerError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v2/admin/cards/:id
|
||||
/// Get a specific card by ID
|
||||
@Route.get('/admin/cards/<cardId>')
|
||||
@Route.get('/cards/<cardId>')
|
||||
Future<Response> getCard(Request request, String cardId) async {
|
||||
try {
|
||||
final auth = await _ensureAdmin(request);
|
||||
if (auth.statusCode != 200) {
|
||||
return auth;
|
||||
final id = int.tryParse(cardId);
|
||||
if (id == null) {
|
||||
return Response.badRequest(
|
||||
body: json.encode({'error': 'Invalid card ID'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
final cardIdInt = int.tryParse(cardId);
|
||||
if (cardIdInt == null) {
|
||||
return _badRequest('Invalid card ID');
|
||||
}
|
||||
|
||||
final card = await backend_main.isar.txn(() async {
|
||||
return await backend_main.isar.gameCardModels.get(cardIdInt);
|
||||
});
|
||||
|
||||
final card = await _db.packDao.getCardById(id);
|
||||
if (card == null) {
|
||||
return _notFound('Card not found');
|
||||
return Response.notFound(
|
||||
json.encode({'error': 'Card not found'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
return _json(card.toDto().toJson());
|
||||
} catch (e, s) {
|
||||
print('Error in getCard: $e\n$s');
|
||||
return _internalServerError(e.toString());
|
||||
return Response.ok(
|
||||
json.encode({
|
||||
'id': card.id,
|
||||
'packId': card.packId,
|
||||
'original': card.original,
|
||||
'translation': card.translation,
|
||||
'mnemo': card.mnemo,
|
||||
'image': card.image,
|
||||
'back': card.back,
|
||||
'transcription': card.transcription,
|
||||
'createdAt': card.createdAt.toIso8601String(),
|
||||
'updatedAt': card.updatedAt?.toIso8601String(),
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': e.toString()}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/v2/admin/cards
|
||||
/// Create or update a card
|
||||
@Route.post('/admin/cards')
|
||||
Future<Response> upsertCard(Request request) async {
|
||||
@Route.post('/cards')
|
||||
Future<Response> createCard(Request request) async {
|
||||
try {
|
||||
final auth = await _ensureAdmin(request);
|
||||
if (auth.statusCode != 200) {
|
||||
return auth;
|
||||
final body = await request.readAsString();
|
||||
final data = json.decode(body) as Map<String, dynamic>;
|
||||
|
||||
final companion = GameCardsCompanion.insert(
|
||||
packId: data['packId'] as int,
|
||||
original: data['original'] as String,
|
||||
translation: data['translation'] 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(),
|
||||
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(),
|
||||
);
|
||||
|
||||
final cardId = await _db.packDao.createCard(companion);
|
||||
|
||||
return Response.ok(
|
||||
json.encode({'success': true, 'cardId': cardId}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': e.toString(), 'success': false}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Route.put('/cards/<cardId>')
|
||||
Future<Response> updateCard(Request request, String cardId) async {
|
||||
try {
|
||||
final id = int.tryParse(cardId);
|
||||
if (id == null) {
|
||||
return Response.badRequest(
|
||||
body: json.encode({'error': 'Invalid card ID'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
final body = await request.readAsString();
|
||||
if (body.isEmpty) {
|
||||
return _json(
|
||||
{
|
||||
'error': 'bad_request',
|
||||
'message': 'Card payload is required',
|
||||
},
|
||||
statusCode: 400,
|
||||
final data = json.decode(body) as Map<String, dynamic>;
|
||||
|
||||
final existing = await _db.packDao.getCardById(id);
|
||||
if (existing == null) {
|
||||
return Response.notFound(
|
||||
json.encode({'error': 'Card not found'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
late final GameCardDto cardDto;
|
||||
try {
|
||||
cardDto =
|
||||
GameCardDto.fromJson(jsonDecode(body) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return _json(
|
||||
{
|
||||
'error': 'bad_request',
|
||||
'message': 'Invalid card payload',
|
||||
},
|
||||
statusCode: 400,
|
||||
);
|
||||
}
|
||||
final updated = existing.copyWith(
|
||||
original: data['original'] ?? existing.original,
|
||||
translation: data['translation'] ?? existing.translation,
|
||||
mnemo: data['mnemo'] ?? existing.mnemo,
|
||||
image: data['image'] ?? existing.image,
|
||||
back: data['back'] ?? existing.back,
|
||||
transcription: data['transcription'] ?? existing.transcription,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
// Validate required fields
|
||||
if (cardDto.original?.isEmpty ?? true) {
|
||||
return _json(
|
||||
{
|
||||
'error': 'bad_request',
|
||||
'message': 'Original text is required',
|
||||
},
|
||||
statusCode: 400,
|
||||
);
|
||||
}
|
||||
await _db.packDao.updateCard(updated);
|
||||
|
||||
if (cardDto.translation?.isEmpty ?? true) {
|
||||
return _json(
|
||||
{
|
||||
'error': 'bad_request',
|
||||
'message': 'Translation is required',
|
||||
},
|
||||
statusCode: 400,
|
||||
);
|
||||
}
|
||||
|
||||
if (cardDto.mnemo?.isEmpty ?? true) {
|
||||
return _json(
|
||||
{
|
||||
'error': 'bad_request',
|
||||
'message': 'Mnemo is required',
|
||||
},
|
||||
statusCode: 400,
|
||||
);
|
||||
}
|
||||
|
||||
// Convert DTO to model
|
||||
final cardModel = cardDto.toModel();
|
||||
|
||||
// Save to database
|
||||
await backend_main.isar.writeTxn(() async {
|
||||
await backend_main.isar.gameCardModels.put(cardModel);
|
||||
});
|
||||
|
||||
// Get updated card with ID
|
||||
final updatedCard = await backend_main.isar.txn(() async {
|
||||
return await backend_main.isar.gameCardModels.get(cardModel.id!);
|
||||
});
|
||||
|
||||
if (updatedCard == null) {
|
||||
return _internalServerError('Failed to save card');
|
||||
}
|
||||
|
||||
return _json({
|
||||
'success': true,
|
||||
'card': updatedCard.toDto().toJson(),
|
||||
});
|
||||
} catch (e, s) {
|
||||
print('Error in upsertCard: $e\n$s');
|
||||
return _internalServerError(e.toString());
|
||||
return Response.ok(
|
||||
json.encode({'success': true}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': e.toString(), 'success': false}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/v2/admin/cards/:id
|
||||
/// Delete a card by ID
|
||||
@Route.delete('/admin/cards/<cardId>')
|
||||
@Route.delete('/cards/<cardId>')
|
||||
Future<Response> deleteCard(Request request, String cardId) async {
|
||||
try {
|
||||
final auth = await _ensureAdmin(request);
|
||||
if (auth.statusCode != 200) {
|
||||
return auth;
|
||||
final id = int.tryParse(cardId);
|
||||
if (id == null) {
|
||||
return Response.badRequest(
|
||||
body: json.encode({'error': 'Invalid card ID'}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
|
||||
final cardIdInt = int.tryParse(cardId);
|
||||
if (cardIdInt == null) {
|
||||
return _badRequest('Invalid card ID');
|
||||
}
|
||||
await _db.packDao.deleteCard(id);
|
||||
|
||||
final card = await backend_main.isar.txn(() async {
|
||||
return await backend_main.isar.gameCardModels.get(cardIdInt);
|
||||
});
|
||||
|
||||
if (card == null) {
|
||||
return _notFound('Card not found');
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await backend_main.isar.writeTxn(() async {
|
||||
await backend_main.isar.gameCardModels.delete(cardIdInt);
|
||||
});
|
||||
|
||||
return _json({
|
||||
'success': true,
|
||||
'message': 'Card deleted successfully',
|
||||
});
|
||||
} catch (e, s) {
|
||||
print('Error in deleteCard: $e\n$s');
|
||||
return _internalServerError(e.toString());
|
||||
return Response.ok(
|
||||
json.encode({'success': true}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
} catch (e) {
|
||||
return Response.internalServerError(
|
||||
body: json.encode({'error': e.toString(), 'success': false}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Router get router => _$AdminCardsApiV2Router(this);
|
||||
}
|
||||
Handler get handler => _$AdminCardsApiV2Router(this);
|
||||
}
|
||||
299
mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart.backup
Normal file
299
mnemo_cards_backend/lib/api/v2/admin_cards_api_v2.dart.backup
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/api/authorize/acl_types.dart';
|
||||
import 'package:mnemo_cards_backend/api/authorize/access_service.dart';
|
||||
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
||||
import 'package:mnemo_cards_backend/main.dart' as backend_main;
|
||||
import 'package:mnemo_cards_backend/packs/card_model_extension.dart';
|
||||
import 'package:mnemo_cards_backend/packs/card_dto_extension.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
|
||||
part 'admin_cards_api_v2.g.dart';
|
||||
|
||||
/// Admin endpoints for card management in API v2.
|
||||
@lazySingleton
|
||||
class AdminCardsApiV2 {
|
||||
AdminCardsApiV2();
|
||||
|
||||
Response _json(
|
||||
Object? data, {
|
||||
int statusCode = 200,
|
||||
Map<String, String> headers = const {},
|
||||
}) {
|
||||
return Response(
|
||||
statusCode,
|
||||
body: data == null ? null : jsonEncode(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Response _badRequest(String message) => Response.badRequest(
|
||||
body: jsonEncode({'error': 'Bad Request', 'message': message}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
Response _notFound([String? message]) => Response.notFound(
|
||||
jsonEncode({
|
||||
'error': 'Not Found',
|
||||
'message': message ?? 'Resource not found',
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
Response _internalServerError([String? message]) => Response(
|
||||
500,
|
||||
body: jsonEncode({
|
||||
'error': 'Internal Server Error',
|
||||
'message': message ?? 'An error occurred',
|
||||
}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
Future<Response> _ensureAdmin(Request request) async {
|
||||
try {
|
||||
await request.access!
|
||||
.requireAdmin(AdminAction.access, user: request.user);
|
||||
return Response.ok(null);
|
||||
} on AccessDenied catch (e) {
|
||||
return Response(e.status, body: e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v2/admin/cards
|
||||
/// Get all cards with pagination and optional search
|
||||
/// Query params: ?page=1&limit=20&search=term
|
||||
@Route.get('/admin/cards')
|
||||
Future<Response> getCards(Request request) async {
|
||||
try {
|
||||
final auth = await _ensureAdmin(request);
|
||||
if (auth.statusCode != 200) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
final queryParams = request.requestedUri.queryParameters;
|
||||
|
||||
// Parse pagination parameters
|
||||
final page = int.tryParse(queryParams['page'] ?? '1') ?? 1;
|
||||
final limit = int.tryParse(queryParams['limit'] ?? '20') ?? 20;
|
||||
final search = queryParams['search']?.trim();
|
||||
|
||||
// Validate pagination
|
||||
if (page < 1) {
|
||||
return _badRequest('Page must be greater than 0');
|
||||
}
|
||||
if (limit < 1 || limit > 100) {
|
||||
return _badRequest('Limit must be between 1 and 100');
|
||||
}
|
||||
|
||||
// Get all cards from database
|
||||
final allCards = await backend_main.isar
|
||||
.txn(() async => backend_main.isar.gameCardModels.where().findAll());
|
||||
|
||||
// Apply search filter if provided
|
||||
List<GameCardModel> filteredCards = allCards;
|
||||
if (search != null && search.isNotEmpty) {
|
||||
final searchLower = search.toLowerCase();
|
||||
filteredCards = allCards.where((card) {
|
||||
return card.original.toLowerCase().contains(searchLower) ||
|
||||
card.translation.toLowerCase().contains(searchLower) ||
|
||||
card.mnemo.toLowerCase().contains(searchLower) ||
|
||||
(card.transcription?.toLowerCase().contains(searchLower) ??
|
||||
false) ||
|
||||
(card.transcriptionMnemo?.toLowerCase().contains(searchLower) ??
|
||||
false);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
final total = filteredCards.length;
|
||||
final totalPages = (total / limit).ceil();
|
||||
final offset = (page - 1) * limit;
|
||||
final paginatedCards = filteredCards.skip(offset).take(limit).toList();
|
||||
|
||||
// Convert to DTOs
|
||||
final cardDtos = paginatedCards.map((card) => card.toDto()).toList();
|
||||
|
||||
return _json({
|
||||
'items': cardDtos.map((c) => c.toJson()).toList(),
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'totalPages': totalPages,
|
||||
});
|
||||
} catch (e, s) {
|
||||
print('Error in getCards: $e\n$s');
|
||||
return _internalServerError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v2/admin/cards/:id
|
||||
/// Get a specific card by ID
|
||||
@Route.get('/admin/cards/<cardId>')
|
||||
Future<Response> getCard(Request request, String cardId) async {
|
||||
try {
|
||||
final auth = await _ensureAdmin(request);
|
||||
if (auth.statusCode != 200) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
final cardIdInt = int.tryParse(cardId);
|
||||
if (cardIdInt == null) {
|
||||
return _badRequest('Invalid card ID');
|
||||
}
|
||||
|
||||
final card = await backend_main.isar.txn(() async {
|
||||
return await backend_main.isar.gameCardModels.get(cardIdInt);
|
||||
});
|
||||
|
||||
if (card == null) {
|
||||
return _notFound('Card not found');
|
||||
}
|
||||
|
||||
return _json(card.toDto().toJson());
|
||||
} catch (e, s) {
|
||||
print('Error in getCard: $e\n$s');
|
||||
return _internalServerError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/v2/admin/cards
|
||||
/// Create or update a card
|
||||
@Route.post('/admin/cards')
|
||||
Future<Response> upsertCard(Request request) async {
|
||||
try {
|
||||
final auth = await _ensureAdmin(request);
|
||||
if (auth.statusCode != 200) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
final body = await request.readAsString();
|
||||
if (body.isEmpty) {
|
||||
return _json(
|
||||
{
|
||||
'error': 'bad_request',
|
||||
'message': 'Card payload is required',
|
||||
},
|
||||
statusCode: 400,
|
||||
);
|
||||
}
|
||||
|
||||
late final GameCardDto cardDto;
|
||||
try {
|
||||
cardDto =
|
||||
GameCardDto.fromJson(jsonDecode(body) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return _json(
|
||||
{
|
||||
'error': 'bad_request',
|
||||
'message': 'Invalid card payload',
|
||||
},
|
||||
statusCode: 400,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (cardDto.original?.isEmpty ?? true) {
|
||||
return _json(
|
||||
{
|
||||
'error': 'bad_request',
|
||||
'message': 'Original text is required',
|
||||
},
|
||||
statusCode: 400,
|
||||
);
|
||||
}
|
||||
|
||||
if (cardDto.translation?.isEmpty ?? true) {
|
||||
return _json(
|
||||
{
|
||||
'error': 'bad_request',
|
||||
'message': 'Translation is required',
|
||||
},
|
||||
statusCode: 400,
|
||||
);
|
||||
}
|
||||
|
||||
if (cardDto.mnemo?.isEmpty ?? true) {
|
||||
return _json(
|
||||
{
|
||||
'error': 'bad_request',
|
||||
'message': 'Mnemo is required',
|
||||
},
|
||||
statusCode: 400,
|
||||
);
|
||||
}
|
||||
|
||||
// Convert DTO to model
|
||||
final cardModel = cardDto.toModel();
|
||||
|
||||
// Save to database
|
||||
await backend_main.isar.writeTxn(() async {
|
||||
await backend_main.isar.gameCardModels.put(cardModel);
|
||||
});
|
||||
|
||||
// Get updated card with ID
|
||||
final updatedCard = await backend_main.isar.txn(() async {
|
||||
return await backend_main.isar.gameCardModels.get(cardModel.id!);
|
||||
});
|
||||
|
||||
if (updatedCard == null) {
|
||||
return _internalServerError('Failed to save card');
|
||||
}
|
||||
|
||||
return _json({
|
||||
'success': true,
|
||||
'card': updatedCard.toDto().toJson(),
|
||||
});
|
||||
} catch (e, s) {
|
||||
print('Error in upsertCard: $e\n$s');
|
||||
return _internalServerError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/v2/admin/cards/:id
|
||||
/// Delete a card by ID
|
||||
@Route.delete('/admin/cards/<cardId>')
|
||||
Future<Response> deleteCard(Request request, String cardId) async {
|
||||
try {
|
||||
final auth = await _ensureAdmin(request);
|
||||
if (auth.statusCode != 200) {
|
||||
return auth;
|
||||
}
|
||||
|
||||
final cardIdInt = int.tryParse(cardId);
|
||||
if (cardIdInt == null) {
|
||||
return _badRequest('Invalid card ID');
|
||||
}
|
||||
|
||||
final card = await backend_main.isar.txn(() async {
|
||||
return await backend_main.isar.gameCardModels.get(cardIdInt);
|
||||
});
|
||||
|
||||
if (card == null) {
|
||||
return _notFound('Card not found');
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await backend_main.isar.writeTxn(() async {
|
||||
await backend_main.isar.gameCardModels.delete(cardIdInt);
|
||||
});
|
||||
|
||||
return _json({
|
||||
'success': true,
|
||||
'message': 'Card deleted successfully',
|
||||
});
|
||||
} catch (e, s) {
|
||||
print('Error in deleteCard: $e\n$s');
|
||||
return _internalServerError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Router get router => _$AdminCardsApiV2Router(this);
|
||||
}
|
||||
|
|
@ -10,22 +10,27 @@ Router _$AdminCardsApiV2Router(AdminCardsApiV2 service) {
|
|||
final router = Router();
|
||||
router.add(
|
||||
'GET',
|
||||
r'/admin/cards',
|
||||
service.getCards,
|
||||
r'/cards',
|
||||
service.getAllCards,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/admin/cards/<cardId>',
|
||||
r'/cards/<cardId>',
|
||||
service.getCard,
|
||||
);
|
||||
router.add(
|
||||
'POST',
|
||||
r'/admin/cards',
|
||||
service.upsertCard,
|
||||
r'/cards',
|
||||
service.createCard,
|
||||
);
|
||||
router.add(
|
||||
'PUT',
|
||||
r'/cards/<cardId>',
|
||||
service.updateCard,
|
||||
);
|
||||
router.add(
|
||||
'DELETE',
|
||||
r'/admin/cards/<cardId>',
|
||||
r'/cards/<cardId>',
|
||||
service.deleteCard,
|
||||
);
|
||||
return router;
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'admin_packs_api_v2.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// ShelfRouterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Router _$AdminPacksApiV2Router(AdminPacksApiV2 service) {
|
||||
final router = Router();
|
||||
router.add(
|
||||
'GET',
|
||||
r'/admin/packs',
|
||||
service.getPacks,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/admin/packs/<packId>',
|
||||
service.getPack,
|
||||
);
|
||||
router.add(
|
||||
'POST',
|
||||
r'/admin/packs',
|
||||
service.upsertPack,
|
||||
);
|
||||
router.add(
|
||||
'DELETE',
|
||||
r'/admin/packs/<packId>',
|
||||
service.deletePack,
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'admin_users_api_v2.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// ShelfRouterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Router _$AdminUsersApiV2Router(AdminUsersApiV2 service) {
|
||||
final router = Router();
|
||||
router.add(
|
||||
'GET',
|
||||
r'/admin/users',
|
||||
service.getUsers,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/admin/users/ids',
|
||||
service.getUserIds,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/admin/users/<userId>/purchases',
|
||||
service.getUserPurchases,
|
||||
);
|
||||
router.add(
|
||||
'POST',
|
||||
r'/admin/users',
|
||||
service.upsertUser,
|
||||
);
|
||||
router.add(
|
||||
'DELETE',
|
||||
r'/admin/users/<userId>',
|
||||
service.deleteUser,
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'ads_api_v2.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// ShelfRouterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Router _$AdsApiV2Router(AdsApiV2 service) {
|
||||
final router = Router();
|
||||
router.add(
|
||||
'POST',
|
||||
r'/ads/product/acquire/<key>',
|
||||
service.acquireProductForAd,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/adsgram/reward',
|
||||
service.adsgramRewardCallback,
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
|||
import 'package:mnemo_cards_backend/api/v2/jwt_service.dart';
|
||||
import 'package:mnemo_cards_backend/api/user/google_api.dart';
|
||||
import 'package:mnemo_cards_backend/auth/telegram_auth_code_service.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/user/telegram.dart';
|
||||
import 'package:mnemo_cards_backend/user/user_manager.dart';
|
||||
import 'package:mnemo_cards_backend/user/user_model.dart';
|
||||
|
|
@ -20,12 +21,14 @@ part 'auth_api_v2.g.dart';
|
|||
/// Implements OAuth2/JWT Bearer token authentication
|
||||
@lazySingleton
|
||||
class AuthApiV2 {
|
||||
final AppDatabase _db;
|
||||
final UserManager _userManager;
|
||||
final GoogleApi _googleApi;
|
||||
final JwtService _jwtService;
|
||||
final TelegramAuthCodeService _telegramAuthCodeService;
|
||||
|
||||
AuthApiV2(
|
||||
this._db,
|
||||
this._userManager,
|
||||
this._googleApi,
|
||||
this._jwtService,
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'games_api_v2.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// ShelfRouterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Router _$GamesApiV2Router(GamesApiV2 service) {
|
||||
final router = Router();
|
||||
router.add(
|
||||
'GET',
|
||||
r'/games',
|
||||
service.getGames,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/games/<gameId>/assets',
|
||||
service.getGameAssets,
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/main.dart' as main;
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
|
||||
/// Service for JWT token generation and verification
|
||||
|
|
@ -12,12 +12,18 @@ import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
|||
/// Generates access tokens (short-lived) and refresh tokens (long-lived)
|
||||
@lazySingleton
|
||||
class JwtService {
|
||||
// In production, use environment variables or secure key management
|
||||
static const String _secretKey = 'your-secret-key-change-in-production';
|
||||
// Read secrets from environment variables
|
||||
static final String _jwtSecret = Platform.environment['JWT_SECRET'] ??
|
||||
'dev-jwt-secret-change-me-in-production';
|
||||
static final String _jwtRefreshSecret = Platform.environment['JWT_REFRESH_SECRET'] ??
|
||||
'dev-refresh-secret-change-me-in-production';
|
||||
|
||||
static const int _accessTokenExpirySeconds = 3600; // 1 hour
|
||||
static const int _refreshTokenExpirySeconds = 2592000; // 30 days
|
||||
|
||||
JwtService();
|
||||
final AppDatabase _db;
|
||||
|
||||
JwtService(this._db);
|
||||
|
||||
/// Generate access and refresh tokens for a user
|
||||
Future<JwtTokens> generateTokens(UserModel user) async {
|
||||
|
|
@ -141,9 +147,13 @@ class JwtService {
|
|||
final headerB64 = base64UrlEncode(utf8.encode(jsonEncode(header)));
|
||||
final payloadB64 = base64UrlEncode(utf8.encode(jsonEncode(payload)));
|
||||
|
||||
// Create signature (simplified - in production use proper HMAC)
|
||||
// Use different secrets for access and refresh tokens
|
||||
final isRefreshToken = payload['type'] == 'refresh';
|
||||
final secret = isRefreshToken ? _jwtRefreshSecret : _jwtSecret;
|
||||
|
||||
// Create signature with HMAC-SHA256
|
||||
final signatureInput = '$headerB64.$payloadB64';
|
||||
final signature = _hmacSha256(utf8.encode(signatureInput), _secretKey);
|
||||
final signature = _hmacSha256(utf8.encode(signatureInput), secret);
|
||||
final signatureB64 = base64UrlEncode(signature);
|
||||
|
||||
return '$headerB64.$payloadB64.$signatureB64';
|
||||
|
|
@ -162,10 +172,17 @@ class JwtService {
|
|||
final payloadB64 = parts[1];
|
||||
final signatureB64 = parts[2];
|
||||
|
||||
// Verify signature
|
||||
// Decode payload first to determine token type
|
||||
final payloadJson = utf8.decode(base64Url.decode(payloadB64));
|
||||
final payload = jsonDecode(payloadJson) as Map<String, dynamic>;
|
||||
|
||||
// Use different secrets for access and refresh tokens
|
||||
final isRefreshToken = payload['type'] == 'refresh';
|
||||
final secret = isRefreshToken ? _jwtRefreshSecret : _jwtSecret;
|
||||
|
||||
// Verify signature with appropriate secret
|
||||
final signatureInput = '$headerB64.$payloadB64';
|
||||
final expectedSignature =
|
||||
_hmacSha256(utf8.encode(signatureInput), _secretKey);
|
||||
final expectedSignature = _hmacSha256(utf8.encode(signatureInput), secret);
|
||||
final expectedSignatureB64 = base64UrlEncode(expectedSignature);
|
||||
|
||||
if (signatureB64 != expectedSignatureB64) {
|
||||
|
|
@ -174,9 +191,7 @@ class JwtService {
|
|||
return null; // Invalid signature
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
final payloadJson = utf8.decode(base64Url.decode(payloadB64));
|
||||
return jsonDecode(payloadJson) as Map<String, dynamic>;
|
||||
return payload;
|
||||
} catch (e) {
|
||||
lastVerificationError = 'Parse exception: $e';
|
||||
return null;
|
||||
|
|
@ -218,32 +233,27 @@ class JwtService {
|
|||
DateTime createdAt,
|
||||
DateTime expiresAt,
|
||||
) async {
|
||||
await main.isar.writeTxn(() async {
|
||||
await _db.transaction(() async {
|
||||
// Delete old token if exists (shouldn't happen due to unique index, but be safe)
|
||||
final existing = await main.isar.refreshTokenModels
|
||||
.filter()
|
||||
.jtiEqualTo(jti)
|
||||
.findFirst();
|
||||
final existing = await _db.userDao.getRefreshTokenByJti(jti);
|
||||
if (existing != null) {
|
||||
await main.isar.refreshTokenModels.delete(existing.id!);
|
||||
await _db.userDao.revokeRefreshToken(existing.id);
|
||||
}
|
||||
|
||||
// Store new token
|
||||
await main.isar.refreshTokenModels.put(
|
||||
RefreshTokenModel(
|
||||
await _db.userDao.createRefreshToken(
|
||||
RefreshTokensCompanion.insert(
|
||||
jti: jti,
|
||||
userId: userId,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
isBlacklisted: false,
|
||||
expiresAt: expiresAt, // required field - raw DateTime
|
||||
// createdAt and isBlacklisted use defaults from table
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _isTokenBlacklisted(String jti) async {
|
||||
final token =
|
||||
await main.isar.refreshTokenModels.filter().jtiEqualTo(jti).findFirst();
|
||||
final token = await _db.userDao.getRefreshTokenByJti(jti);
|
||||
|
||||
if (token == null) {
|
||||
return true; // Token not found, consider it invalid
|
||||
|
|
@ -259,38 +269,12 @@ class JwtService {
|
|||
|
||||
/// Blacklist a refresh token (for logout)
|
||||
Future<void> blacklistRefreshToken(String jti) async {
|
||||
await main.isar.writeTxn(() async {
|
||||
final token = await main.isar.refreshTokenModels
|
||||
.filter()
|
||||
.jtiEqualTo(jti)
|
||||
.findFirst();
|
||||
if (token != null) {
|
||||
await main.isar.refreshTokenModels.put(
|
||||
token.copyWith(isBlacklisted: true),
|
||||
);
|
||||
}
|
||||
});
|
||||
await _db.userDao.revokeRefreshTokenByJti(jti);
|
||||
}
|
||||
|
||||
/// Clean up expired tokens (should be called periodically)
|
||||
Future<void> cleanupExpiredTokens() async {
|
||||
await main.isar.writeTxn(() async {
|
||||
final now = DateTime.now();
|
||||
final expired = await main.isar.refreshTokenModels
|
||||
.filter()
|
||||
.expiresAtLessThan(now)
|
||||
.findAll();
|
||||
if (expired.isNotEmpty) {
|
||||
final ids = expired
|
||||
.map((t) => t.id)
|
||||
.where((id) => id != null)
|
||||
.cast<Id>()
|
||||
.toList();
|
||||
if (ids.isNotEmpty) {
|
||||
await main.isar.refreshTokenModels.deleteAll(ids);
|
||||
}
|
||||
}
|
||||
});
|
||||
await _db.userDao.deleteExpiredRefreshTokens();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'packs_api_v2.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// ShelfRouterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Router _$PacksApiV2Router(PacksApiV2 service) {
|
||||
final router = Router();
|
||||
router.add(
|
||||
'GET',
|
||||
r'/packs',
|
||||
service.getPacks,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/packs/<packId>',
|
||||
service.getPack,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/packs/<packId>/buy',
|
||||
service.getPackBuyPage,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/packs/<packId>/cards',
|
||||
service.getPackCards,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/packs/<packId>/cards/<cardId>/image',
|
||||
service.getCardImage,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/packs/<packId>/cards/<cardId>/voices',
|
||||
service.getCardVoices,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/voice/<voiceId>',
|
||||
service.getVoiceFile,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/packs/<packId>/tests',
|
||||
service.getPackTests,
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'purchases_api_v2.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// ShelfRouterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
Router _$PurchasesApiV2Router(PurchasesApiV2 service) {
|
||||
final router = Router();
|
||||
router.add(
|
||||
'POST',
|
||||
r'/purchases/packs/<packId>',
|
||||
service.createPackPurchase,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/purchases/packs/<packId>/status',
|
||||
service.getPackPurchaseStatus,
|
||||
);
|
||||
router.add(
|
||||
'POST',
|
||||
r'/purchases/payments',
|
||||
service.createPayment,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
r'/purchases/payments/<paymentId>/verify',
|
||||
service.verifyPayment,
|
||||
);
|
||||
return router;
|
||||
}
|
||||
|
|
@ -2,21 +2,19 @@ import 'dart:convert';
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
||||
import 'package:mnemo_cards_backend/main.dart' as backend_main;
|
||||
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';
|
||||
import 'package:shelf_router/shelf_router.dart';
|
||||
|
||||
import '../../main.dart';
|
||||
|
||||
part 'tasks_api_v2.g.dart';
|
||||
|
||||
/// API v2 endpoints for user tasks management
|
||||
@lazySingleton
|
||||
class TasksApiV2 {
|
||||
const TasksApiV2();
|
||||
final TaskManager _taskManager;
|
||||
|
||||
const TasksApiV2(this._taskManager);
|
||||
|
||||
Response _json(
|
||||
Object? data, {
|
||||
|
|
@ -82,40 +80,44 @@ class TasksApiV2 {
|
|||
final limit = int.tryParse(queryParams['limit'] ?? '50') ?? 50;
|
||||
final offset = int.tryParse(queryParams['offset'] ?? '0') ?? 0;
|
||||
|
||||
// Build query
|
||||
var builder = isar.userTaskModels.where().filter();
|
||||
// Get tasks using TaskManager
|
||||
final tasks = await _taskManager.getUserTasks(
|
||||
userId,
|
||||
type: type,
|
||||
difficulty: difficulty,
|
||||
status: status,
|
||||
tags: tags,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
|
||||
if (type != null) {
|
||||
builder = builder.typeEqualTo(type);
|
||||
}
|
||||
// Convert to JSON format
|
||||
final tasksJson = tasks.map((t) => {
|
||||
'id': t.id,
|
||||
'title': t.title,
|
||||
'description': t.description,
|
||||
'type': t.type,
|
||||
'difficulty': t.difficulty,
|
||||
'status': t.status,
|
||||
'rewards': t.rewards,
|
||||
'createdAt': t.createdAt.toIso8601String(),
|
||||
'expiresAt': t.expiresAt.toIso8601String(),
|
||||
'completedAt': t.completedAt?.toIso8601String(),
|
||||
'proofUrl': t.proofUrl,
|
||||
'instructions': t.instructions,
|
||||
'tags': t.tags,
|
||||
'imageUrl': t.imageUrl,
|
||||
}).toList();
|
||||
|
||||
if (difficulty != null) {
|
||||
builder = builder.difficultyEqualTo(difficulty);
|
||||
}
|
||||
|
||||
if (status != null) {
|
||||
builder = builder.statusEqualTo(status);
|
||||
}
|
||||
|
||||
if (tags != null && tags.isNotEmpty) {
|
||||
// For simplicity, we'll check if any tag matches
|
||||
// In a more complex scenario, you might want to use a more sophisticated query
|
||||
final allTasks = await builder.idIsNotNull().findAll();
|
||||
final filteredTasks = allTasks.where((task) {
|
||||
return tags.any((tag) => task.tags.contains(tag));
|
||||
}).toList();
|
||||
|
||||
final tasks = filteredTasks.skip(offset).take(limit).toList();
|
||||
return _json({'tasks': tasks.map((t) => t.toJson()).toList()});
|
||||
}
|
||||
|
||||
final tasks =
|
||||
await builder.idIsNotNull().offset(offset).limit(limit).findAll();
|
||||
|
||||
return _json({'tasks': tasks.map((t) => t.toJson()).toList()});
|
||||
return _json({
|
||||
'tasks': tasksJson,
|
||||
'total': tasks.length, // TODO: Get actual total count
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
});
|
||||
} catch (e) {
|
||||
log('Error fetching tasks: $e');
|
||||
return _serverError('Failed to fetch tasks');
|
||||
return _serverError('Failed to fetch tasks: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -129,13 +131,30 @@ class TasksApiV2 {
|
|||
final taskIdInt = int.tryParse(taskId);
|
||||
if (taskIdInt == null) return _badRequest('Invalid task ID');
|
||||
|
||||
final task = await isar.userTaskModels.get(taskIdInt);
|
||||
final task = await _taskManager.getUserTask(userId, taskIdInt);
|
||||
if (task == null) return _notFound('Task not found');
|
||||
|
||||
return _json({'task': task.toJson()});
|
||||
return _json({
|
||||
'task': {
|
||||
'id': task.id,
|
||||
'title': task.title,
|
||||
'description': task.description,
|
||||
'type': task.type,
|
||||
'difficulty': task.difficulty,
|
||||
'status': task.status,
|
||||
'rewards': task.rewards,
|
||||
'createdAt': task.createdAt.toIso8601String(),
|
||||
'expiresAt': task.expiresAt.toIso8601String(),
|
||||
'completedAt': task.completedAt?.toIso8601String(),
|
||||
'proofUrl': task.proofUrl,
|
||||
'instructions': task.instructions,
|
||||
'tags': task.tags,
|
||||
'imageUrl': task.imageUrl,
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
log('Error fetching task: $e');
|
||||
return _serverError('Failed to fetch task');
|
||||
return _serverError('Failed to fetch task: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -149,28 +168,13 @@ class TasksApiV2 {
|
|||
final taskIdInt = int.tryParse(taskId);
|
||||
if (taskIdInt == null) return _badRequest('Invalid task ID');
|
||||
|
||||
final task = await isar.userTaskModels.get(taskIdInt);
|
||||
if (task == null) return _notFound('Task not found');
|
||||
// Start task using TaskManager
|
||||
await _taskManager.startTask(userId, taskIdInt);
|
||||
|
||||
if (!task.isActive) return _badRequest('Task is not available');
|
||||
|
||||
// Update task status to in_progress
|
||||
final updatedTask = task.copyWith(status: 'in_progress');
|
||||
await isar.writeTxn(() async {
|
||||
await isar.userTaskModels.put(updatedTask);
|
||||
});
|
||||
|
||||
// Update user progress
|
||||
await _updateUserProgress(
|
||||
userId.toString(), taskId.toString(), 'in_progress');
|
||||
|
||||
return _json({
|
||||
'task': updatedTask.toJson(),
|
||||
'message': 'Task started successfully'
|
||||
});
|
||||
return _json({'success': true, 'message': 'Task started successfully'});
|
||||
} catch (e) {
|
||||
log('Error starting task: $e');
|
||||
return _serverError('Failed to start task');
|
||||
return _serverError('Failed to start task: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,193 +188,51 @@ class TasksApiV2 {
|
|||
final taskIdInt = int.tryParse(taskId);
|
||||
if (taskIdInt == null) return _badRequest('Invalid task ID');
|
||||
|
||||
final body = await request.readAsString();
|
||||
final data = jsonDecode(body) as Map<String, dynamic>;
|
||||
// Complete task using TaskManager
|
||||
await _taskManager.completeTask(userId, taskIdInt);
|
||||
|
||||
final proofUrl = data['proofUrl'] as String?;
|
||||
final notes = data['notes'] as String?;
|
||||
|
||||
final task = await isar.userTaskModels.get(taskIdInt);
|
||||
if (task == null) return _notFound('Task not found');
|
||||
|
||||
if (task.status != 'in_progress') {
|
||||
return _badRequest('Task is not in progress');
|
||||
}
|
||||
|
||||
// Update task status to completed
|
||||
final now = DateTime.now();
|
||||
final updatedTask = task.copyWith(
|
||||
status: 'completed',
|
||||
completedAt: now,
|
||||
proofUrl: proofUrl,
|
||||
);
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
await isar.userTaskModels.put(updatedTask);
|
||||
|
||||
// Create task result record
|
||||
final result = UserTaskResultModel(
|
||||
taskId: taskId.toString(),
|
||||
userId: userId.toString(),
|
||||
status: 'completed',
|
||||
submittedAt: now,
|
||||
proofUrl: proofUrl,
|
||||
notes: notes,
|
||||
);
|
||||
await isar.userTaskResultModels.put(result);
|
||||
});
|
||||
|
||||
// Update user progress with rewards
|
||||
await _updateUserProgress(
|
||||
userId.toString(), taskId.toString(), 'completed',
|
||||
rewards: updatedTask.rewards);
|
||||
|
||||
return _json({
|
||||
'task': updatedTask.toJson(),
|
||||
'message': 'Task completed successfully',
|
||||
'rewards': updatedTask.rewards.map((r) => r.toJson()).toList(),
|
||||
});
|
||||
return _json({'success': true, 'message': 'Task completed successfully'});
|
||||
} catch (e) {
|
||||
log('Error completing task: $e');
|
||||
return _serverError('Failed to complete task');
|
||||
return _serverError('Failed to complete task: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v2/users/me/tasks/progress - Get user task progress
|
||||
@Route.get('/users/me/tasks/progress')
|
||||
Future<Response> getUserProgress(Request request) async {
|
||||
Future<Response> getUserTaskProgress(Request request) async {
|
||||
final userId = request.user?.id;
|
||||
if (userId == null) return _unauthorized();
|
||||
|
||||
try {
|
||||
final progress = await isar.userTaskProgressModels
|
||||
.filter()
|
||||
.userIdEqualTo(userId.toString())
|
||||
.findFirst();
|
||||
|
||||
if (progress == null) {
|
||||
// Create initial progress if it doesn't exist
|
||||
final newProgress = UserTaskProgressModel(
|
||||
userId: userId.toString(),
|
||||
taskStatusesJson: '{}',
|
||||
completedTasksJson: '{}',
|
||||
totalXp: 0,
|
||||
totalCoins: 0,
|
||||
achievementsJson: '[]',
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
await isar.userTaskProgressModels.put(newProgress);
|
||||
});
|
||||
|
||||
return _json({'progress': newProgress.toJson()});
|
||||
}
|
||||
|
||||
return _json({'progress': progress.toJson()});
|
||||
} catch (e) {
|
||||
log('Error fetching user progress: $e');
|
||||
return _serverError('Failed to fetch user progress');
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v2/tasks/categories - Get available task categories and filters
|
||||
@Route.get('/tasks/categories')
|
||||
Future<Response> getTaskCategories(Request request) async {
|
||||
final userId = request.user?.id;
|
||||
if (userId == null) return _unauthorized();
|
||||
|
||||
try {
|
||||
// Get all tasks to extract categories and filters
|
||||
final tasks = await isar.userTaskModels.where().findAll();
|
||||
|
||||
final types = tasks.map((t) => t.type).toSet().toList();
|
||||
final difficulties = tasks.map((t) => t.difficulty).toSet().toList();
|
||||
final allTags = tasks.expand((t) => t.tags).toSet().toList();
|
||||
final progress = await _taskManager.getUserTaskProgress(userId);
|
||||
|
||||
return _json({
|
||||
'categories': {
|
||||
'types': types,
|
||||
'difficulties': difficulties,
|
||||
'tags': allTags,
|
||||
}
|
||||
'progress': progress.map((p) => {
|
||||
'taskId': p.taskId,
|
||||
'progress': p.progress,
|
||||
'startedAt': p.startedAt.toIso8601String(),
|
||||
'updatedAt': p.updatedAt.toIso8601String(),
|
||||
}).toList()
|
||||
});
|
||||
} catch (e) {
|
||||
log('Error fetching task progress: $e');
|
||||
return _serverError('Failed to fetch task progress: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v2/tasks/categories - Get task categories
|
||||
@Route.get('/tasks/categories')
|
||||
Future<Response> getTaskCategories(Request request) async {
|
||||
try {
|
||||
final categories = await _taskManager.getTaskCategories();
|
||||
|
||||
return _json({'categories': categories});
|
||||
} catch (e) {
|
||||
log('Error fetching task categories: $e');
|
||||
return _serverError('Failed to fetch task categories');
|
||||
return _serverError('Failed to fetch task categories: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper method to update user progress
|
||||
Future<void> _updateUserProgress(
|
||||
String userId,
|
||||
String taskId,
|
||||
String status, {
|
||||
List<TaskRewardModel>? rewards,
|
||||
}) async {
|
||||
final progress = await isar.userTaskProgressModels
|
||||
.filter()
|
||||
.userIdEqualTo(userId)
|
||||
.findFirst();
|
||||
|
||||
if (progress == null) return;
|
||||
|
||||
final taskStatuses = Map<String, String>.from(progress.taskStatuses);
|
||||
taskStatuses[taskId] = status;
|
||||
|
||||
int totalXp = progress.totalXp;
|
||||
int totalCoins = progress.totalCoins;
|
||||
final achievements = List<String>.from(progress.achievements);
|
||||
|
||||
if (status == 'completed' && rewards != null) {
|
||||
final completedTasks =
|
||||
Map<String, DateTime>.from(progress.completedTasks);
|
||||
completedTasks[taskId] = DateTime.now();
|
||||
|
||||
for (final reward in rewards) {
|
||||
switch (reward.type) {
|
||||
case 'xp':
|
||||
totalXp += reward.amount;
|
||||
break;
|
||||
case 'coins':
|
||||
totalCoins += reward.amount;
|
||||
break;
|
||||
case 'achievement':
|
||||
if (reward.achievementId != null &&
|
||||
!achievements.contains(reward.achievementId)) {
|
||||
achievements.add(reward.achievementId!);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final updatedProgress = progress.copyWith(
|
||||
taskStatusesJson:
|
||||
UserTaskProgressModel.taskStatusesToJson(taskStatuses),
|
||||
completedTasksJson:
|
||||
UserTaskProgressModel.completedTasksToJson(completedTasks),
|
||||
totalXp: totalXp,
|
||||
totalCoins: totalCoins,
|
||||
achievementsJson:
|
||||
UserTaskProgressModel.achievementsToJson(achievements),
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
await isar.userTaskProgressModels.put(updatedProgress);
|
||||
});
|
||||
} else {
|
||||
final updatedProgress = progress.copyWith(
|
||||
taskStatusesJson:
|
||||
UserTaskProgressModel.taskStatusesToJson(taskStatuses),
|
||||
lastUpdated: DateTime.now(),
|
||||
);
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
await isar.userTaskProgressModels.put(updatedProgress);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Router get router => _$TasksApiV2Router(this);
|
||||
}
|
||||
Handler get handler => _$TasksApiV2Router(this);
|
||||
}
|
||||
|
|
@ -31,7 +31,7 @@ Router _$TasksApiV2Router(TasksApiV2 service) {
|
|||
router.add(
|
||||
'GET',
|
||||
r'/users/me/tasks/progress',
|
||||
service.getUserProgress,
|
||||
service.getUserTaskProgress,
|
||||
);
|
||||
router.add(
|
||||
'GET',
|
||||
|
|
|
|||
112
mnemo_cards_backend/lib/database/converters.dart
Normal file
112
mnemo_cards_backend/lib/database/converters.dart
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
// Конвертеры для JSON полей - общие для всех таблиц
|
||||
|
||||
class JsonMapConverter extends TypeConverter<Map<String, dynamic>?, String> {
|
||||
const JsonMapConverter();
|
||||
|
||||
@override
|
||||
Map<String, dynamic>? fromSql(String? fromDb) {
|
||||
if (fromDb == null || fromDb.isEmpty || fromDb == '{}') return null;
|
||||
try {
|
||||
return json.decode(fromDb) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toSql(Map<String, dynamic>? value) {
|
||||
if (value == null || value.isEmpty) return '{}';
|
||||
return json.encode(value);
|
||||
}
|
||||
}
|
||||
|
||||
class JsonListConverter extends TypeConverter<List<dynamic>?, String> {
|
||||
const JsonListConverter();
|
||||
|
||||
@override
|
||||
List<dynamic>? fromSql(String? fromDb) {
|
||||
if (fromDb == null || fromDb.isEmpty || fromDb == '[]') return null;
|
||||
try {
|
||||
return json.decode(fromDb) as List<dynamic>;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toSql(List<dynamic>? value) {
|
||||
if (value == null || value.isEmpty) return '[]';
|
||||
return json.encode(value);
|
||||
}
|
||||
}
|
||||
|
||||
class StringListConverter extends TypeConverter<List<String>, String> {
|
||||
const StringListConverter();
|
||||
|
||||
@override
|
||||
List<String> fromSql(String fromDb) {
|
||||
if (fromDb.isEmpty || fromDb == '[]') return [];
|
||||
try {
|
||||
final decoded = json.decode(fromDb);
|
||||
return (decoded as List).map((e) => e.toString()).toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toSql(List<String> value) {
|
||||
if (value.isEmpty) return '[]';
|
||||
return json.encode(value);
|
||||
}
|
||||
}
|
||||
|
||||
class DateTimeListConverter extends TypeConverter<List<DateTime>?, String> {
|
||||
const DateTimeListConverter();
|
||||
|
||||
@override
|
||||
List<DateTime>? fromSql(String? fromDb) {
|
||||
if (fromDb == null || fromDb.isEmpty || fromDb == '[]') return null;
|
||||
try {
|
||||
final decoded = json.decode(fromDb) as List;
|
||||
return decoded.map((e) {
|
||||
if (e is String) {
|
||||
return DateTime.parse(e);
|
||||
}
|
||||
return DateTime.now();
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toSql(List<DateTime>? value) {
|
||||
if (value == null || value.isEmpty) return '[]';
|
||||
return json.encode(value.map((e) => e.toIso8601String()).toList());
|
||||
}
|
||||
}
|
||||
|
||||
class IntListConverter extends TypeConverter<List<int>, String> {
|
||||
const IntListConverter();
|
||||
|
||||
@override
|
||||
List<int> fromSql(String fromDb) {
|
||||
if (fromDb.isEmpty || fromDb == '[]') return [];
|
||||
try {
|
||||
final decoded = json.decode(fromDb);
|
||||
return (decoded as List).map((e) => e as int).toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toSql(List<int> value) {
|
||||
if (value.isEmpty) return '[]';
|
||||
return json.encode(value);
|
||||
}
|
||||
}
|
||||
65
mnemo_cards_backend/lib/database/daos/achievement_dao.dart
Normal file
65
mnemo_cards_backend/lib/database/daos/achievement_dao.dart
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../database.dart';
|
||||
import '../tables/achievements.dart';
|
||||
|
||||
part 'achievement_dao.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [UserAchievements])
|
||||
class AchievementDao extends DatabaseAccessor<AppDatabase> with _$AchievementDaoMixin {
|
||||
AchievementDao(super.db);
|
||||
|
||||
// ==================== UserAchievements ====================
|
||||
|
||||
/// Получить все достижения пользователя
|
||||
Future<List<UserAchievement>> getUserAchievements(int userId) {
|
||||
return (select(userAchievements)
|
||||
..where((ua) => ua.userId.equals(userId))
|
||||
..orderBy([(ua) => OrderingTerm.desc(ua.unlockedAt)])
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Проверить, есть ли у пользователя достижение
|
||||
Future<bool> hasAchievement(int userId, String achievementId) async {
|
||||
final result = await (select(userAchievements)
|
||||
..where((ua) => ua.userId.equals(userId) & ua.achievementId.equals(achievementId))
|
||||
).getSingleOrNull();
|
||||
return result != null;
|
||||
}
|
||||
|
||||
/// Получить достижение пользователя
|
||||
Future<UserAchievement?> getUserAchievement(int userId, String achievementId) {
|
||||
return (select(userAchievements)
|
||||
..where((ua) => ua.userId.equals(userId) & ua.achievementId.equals(achievementId))
|
||||
).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Создать достижение пользователя
|
||||
Future<int> unlockAchievement(UserAchievementsCompanion achievement) {
|
||||
return into(userAchievements).insert(achievement);
|
||||
}
|
||||
|
||||
/// Обновить прогресс достижения
|
||||
Future<bool> updateAchievementProgress(int userId, String achievementId, double progress) {
|
||||
return (update(userAchievements)
|
||||
..where((ua) => ua.userId.equals(userId) & ua.achievementId.equals(achievementId))
|
||||
).write(UserAchievementsCompanion(
|
||||
progress: Value(progress),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
/// Получить прогресс по всем достижениям пользователя
|
||||
Future<Map<String, double>> getAchievementProgress(int userId) async {
|
||||
final achievements = await getUserAchievements(userId);
|
||||
return Map.fromEntries(
|
||||
achievements.map((a) => MapEntry(a.achievementId, a.progress)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Удалить достижение пользователя (для сброса)
|
||||
Future<void> removeAchievement(int userId, String achievementId) {
|
||||
(delete(userAchievements)
|
||||
..where((ua) => ua.userId.equals(userId) & ua.achievementId.equals(achievementId))
|
||||
).go();
|
||||
}
|
||||
}
|
||||
10
mnemo_cards_backend/lib/database/daos/achievement_dao.g.dart
Normal file
10
mnemo_cards_backend/lib/database/daos/achievement_dao.g.dart
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'achievement_dao.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
mixin _$AchievementDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||
$UsersTable get users => attachedDatabase.users;
|
||||
$UserAchievementsTable get userAchievements =>
|
||||
attachedDatabase.userAchievements;
|
||||
}
|
||||
106
mnemo_cards_backend/lib/database/daos/discount_dao.dart
Normal file
106
mnemo_cards_backend/lib/database/daos/discount_dao.dart
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../database.dart';
|
||||
import '../tables/discounts.dart';
|
||||
import '../tables/users.dart';
|
||||
|
||||
part 'discount_dao.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [DiscountCampaigns, Discounts, DiscountUserDatas])
|
||||
class DiscountDao extends DatabaseAccessor<AppDatabase> with _$DiscountDaoMixin {
|
||||
DiscountDao(super.db);
|
||||
|
||||
// ==================== DiscountCampaigns ====================
|
||||
|
||||
/// Получить кампанию по ID
|
||||
Future<DiscountCampaign?> getCampaignById(int id) {
|
||||
return (select(db.discountCampaigns)..where((c) => c.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить все активные кампании
|
||||
Future<List<DiscountCampaign>> getActiveCampaigns() {
|
||||
final now = DateTime.now();
|
||||
return (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))
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Создать кампанию
|
||||
Future<int> createCampaign(DiscountCampaignsCompanion campaign) {
|
||||
return into(db.discountCampaigns).insert(campaign);
|
||||
}
|
||||
|
||||
/// Обновить кампанию
|
||||
Future<bool> updateCampaign(DiscountCampaign campaign) {
|
||||
return update(db.discountCampaigns).replace(campaign);
|
||||
}
|
||||
|
||||
// ==================== Discounts ====================
|
||||
|
||||
/// Получить скидку по ID
|
||||
Future<Discount?> getDiscountById(int id) {
|
||||
return (select(db.discounts)..where((d) => d.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить скидки кампании
|
||||
Future<List<Discount>> getDiscountsByCampaignId(int campaignId) {
|
||||
return (select(db.discounts)
|
||||
..where((d) => d.campaignId.equals(campaignId))
|
||||
..where((d) => d.isDeleted.equals(false))
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Создать скидку
|
||||
Future<int> createDiscount(DiscountsCompanion discount) {
|
||||
return into(db.discounts).insert(discount);
|
||||
}
|
||||
|
||||
/// Обновить скидку
|
||||
Future<bool> updateDiscount(Discount discount) {
|
||||
return update(db.discounts).replace(discount);
|
||||
}
|
||||
|
||||
// ==================== DiscountUserDatas ====================
|
||||
|
||||
/// Получить скидки пользователя
|
||||
Future<List<Discount>> getUserDiscounts(int userId) async {
|
||||
final query = select(db.discounts).join([
|
||||
innerJoin(
|
||||
db.discountUserDatas,
|
||||
db.discountUserDatas.discountId.equalsExp(db.discounts.id) &
|
||||
db.discountUserDatas.userId.equals(userId),
|
||||
),
|
||||
]);
|
||||
|
||||
return query.map((row) => row.readTable(db.discounts)).get();
|
||||
}
|
||||
|
||||
/// Дать пользователю доступ к скидке
|
||||
Future<void> grantDiscountToUser(int userId, int discountId) async {
|
||||
await into(db.discountUserDatas).insert(
|
||||
DiscountUserDatasCompanion.insert(
|
||||
userId: userId,
|
||||
discountId: discountId,
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
/// Отозвать скидку у пользователя
|
||||
Future<void> revokeDiscountFromUser(int userId, int discountId) async {
|
||||
await (delete(db.discountUserDatas)
|
||||
..where((dud) => dud.userId.equals(userId) & dud.discountId.equals(discountId))
|
||||
).go();
|
||||
}
|
||||
|
||||
/// Проверить, есть ли у пользователя доступ к скидке
|
||||
Future<bool> hasDiscountAccess(int userId, int discountId) async {
|
||||
final query = select(db.discountUserDatas)
|
||||
..where((dud) => dud.userId.equals(userId) & dud.discountId.equals(discountId));
|
||||
|
||||
final result = await query.getSingleOrNull();
|
||||
return result != null;
|
||||
}
|
||||
}
|
||||
13
mnemo_cards_backend/lib/database/daos/discount_dao.g.dart
Normal file
13
mnemo_cards_backend/lib/database/daos/discount_dao.g.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'discount_dao.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
mixin _$DiscountDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||
$DiscountCampaignsTable get discountCampaigns =>
|
||||
attachedDatabase.discountCampaigns;
|
||||
$DiscountsTable get discounts => attachedDatabase.discounts;
|
||||
$UsersTable get users => attachedDatabase.users;
|
||||
$DiscountUserDatasTable get discountUserDatas =>
|
||||
attachedDatabase.discountUserDatas;
|
||||
}
|
||||
294
mnemo_cards_backend/lib/database/daos/pack_dao.dart
Normal file
294
mnemo_cards_backend/lib/database/daos/pack_dao.dart
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../database.dart';
|
||||
import '../tables/packs.dart';
|
||||
import '../tables/relations.dart';
|
||||
|
||||
part 'pack_dao.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [CardPacks, GameCards, VoiceModels, PreviewCards, CardPackCards, CardVoices])
|
||||
class PackDao extends DatabaseAccessor<AppDatabase> with _$PackDaoMixin {
|
||||
PackDao(super.db);
|
||||
|
||||
// ==================== CardPacks ====================
|
||||
|
||||
/// Получить пак по ID
|
||||
Future<CardPack?> getPackById(int id) {
|
||||
return (select(cardPacks)..where((p) => p.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить все паки
|
||||
Future<List<CardPack>> getAllPacks({
|
||||
bool enabledOnly = false,
|
||||
String? orderByField,
|
||||
bool orderDesc = false,
|
||||
}) {
|
||||
final query = select(cardPacks);
|
||||
|
||||
if (enabledOnly) {
|
||||
query.where((p) => p.enabled.equals(true));
|
||||
}
|
||||
|
||||
query.where((p) => p.isDeleted.equals(false));
|
||||
|
||||
if (orderByField == 'order') {
|
||||
if (orderDesc) {
|
||||
query.orderBy([(p) => OrderingTerm.desc(p.order)]);
|
||||
} else {
|
||||
query.orderBy([(p) => OrderingTerm.asc(p.order)]);
|
||||
}
|
||||
} else {
|
||||
query.orderBy([(p) => OrderingTerm.asc(p.order)]);
|
||||
}
|
||||
|
||||
return query.get();
|
||||
}
|
||||
|
||||
/// Создать пак
|
||||
Future<int> createPack(CardPacksCompanion pack) {
|
||||
return into(cardPacks).insert(pack);
|
||||
}
|
||||
|
||||
/// Обновить пак
|
||||
Future<bool> updatePack(CardPack pack) {
|
||||
return update(cardPacks).replace(pack);
|
||||
}
|
||||
|
||||
/// Обновить пак частично
|
||||
Future<void> updatePackPartial(CardPacksCompanion updates) {
|
||||
final packId = updates.id.value;
|
||||
if (packId == null) throw ArgumentError('Pack ID is required');
|
||||
|
||||
return (update(cardPacks)..where((p) => p.id.equals(packId)))
|
||||
.write(updates.copyWith(updatedAt: Value(DateTime.now())));
|
||||
}
|
||||
|
||||
/// Удалить пак (soft delete)
|
||||
Future<void> softDeletePack(int packId) {
|
||||
return (update(cardPacks)..where((p) => p.id.equals(packId)))
|
||||
.write(CardPacksCompanion(
|
||||
isDeleted: const Value(true),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
/// Подсчитать паки
|
||||
Future<int> countPacks({bool enabledOnly = false}) async {
|
||||
final countExpr = cardPacks.id.count();
|
||||
final query = selectOnly(cardPacks)..addColumns([countExpr]);
|
||||
|
||||
if (enabledOnly) {
|
||||
query.where(cardPacks.enabled.equals(true));
|
||||
}
|
||||
query.where(cardPacks.isDeleted.equals(false));
|
||||
|
||||
return await query.map((row) => row.read(countExpr)!).getSingle();
|
||||
}
|
||||
|
||||
// ==================== GameCards ====================
|
||||
|
||||
/// Получить карточку по ID
|
||||
Future<GameCard?> getCardById(int id) {
|
||||
return (select(gameCards)..where((c) => c.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить все карточки пака
|
||||
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();
|
||||
}
|
||||
|
||||
/// Получить карточки по списку ID
|
||||
Future<List<GameCard>> getCardsByIds(List<int> ids) {
|
||||
if (ids.isEmpty) return Future.value([]);
|
||||
return (select(gameCards)..where((c) => c.id.isIn(ids))).get();
|
||||
}
|
||||
|
||||
/// Создать карточку
|
||||
Future<int> createCard(GameCardsCompanion card) {
|
||||
return into(gameCards).insert(card);
|
||||
}
|
||||
|
||||
/// Обновить карточку
|
||||
Future<bool> updateCard(GameCard card) {
|
||||
return update(gameCards).replace(card);
|
||||
}
|
||||
|
||||
/// Удалить карточку (soft delete)
|
||||
Future<void> softDeleteCard(int cardId) {
|
||||
return (update(gameCards)..where((c) => c.id.equals(cardId)))
|
||||
.write(GameCardsCompanion(
|
||||
isDeleted: const Value(true),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
/// Удалить карточку (hard delete)
|
||||
Future<void> deleteCard(int cardId) {
|
||||
return (delete(gameCards)..where((c) => c.id.equals(cardId))).go();
|
||||
}
|
||||
|
||||
/// Получить все карточки с пагинацией
|
||||
Future<List<GameCard>> getAllCards({int? limit, int? offset}) {
|
||||
final query = select(gameCards);
|
||||
if (limit != null) {
|
||||
query.limit(limit, offset: offset);
|
||||
}
|
||||
return query.get();
|
||||
}
|
||||
|
||||
/// Подсчитать карточки
|
||||
Future<int> countCards() async {
|
||||
final countExpr = gameCards.id.count();
|
||||
final query = selectOnly(gameCards)..addColumns([countExpr]);
|
||||
return await query.map((row) => row.read(countExpr)!).getSingle();
|
||||
}
|
||||
|
||||
/// Добавить карточку в пак
|
||||
Future<void> addCardToPack({
|
||||
required int packId,
|
||||
required int cardId,
|
||||
int order = 0,
|
||||
}) async {
|
||||
await into(cardPackCards).insert(
|
||||
CardPackCardsCompanion.insert(
|
||||
packId: packId,
|
||||
cardId: cardId,
|
||||
order: Value(order),
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
/// Удалить карточку из пака
|
||||
Future<void> removeCardFromPack(int packId, int cardId) async {
|
||||
await (delete(cardPackCards)
|
||||
..where((cpc) => cpc.packId.equals(packId) & cpc.cardId.equals(cardId))
|
||||
).go();
|
||||
}
|
||||
|
||||
/// Обновить порядок карточек в паке
|
||||
Future<void> updatePackCardsOrder(int packId, List<int> cardIds) async {
|
||||
await transaction(() async {
|
||||
// Удалить старые связи
|
||||
await (delete(cardPackCards)
|
||||
..where((cpc) => cpc.packId.equals(packId))
|
||||
).go();
|
||||
|
||||
// Создать новые связи с порядком
|
||||
for (var i = 0; i < cardIds.length; i++) {
|
||||
await into(cardPackCards).insert(
|
||||
CardPackCardsCompanion.insert(
|
||||
packId: packId,
|
||||
cardId: cardIds[i],
|
||||
order: Value(i),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== PreviewCards ====================
|
||||
|
||||
/// Получить preview карточки пака
|
||||
Future<List<GameCard>> getPreviewCards(int packId) async {
|
||||
final query = select(gameCards).join([
|
||||
innerJoin(
|
||||
db.previewCards,
|
||||
db.previewCards.cardId.equalsExp(gameCards.id) &
|
||||
db.previewCards.packId.equals(packId),
|
||||
),
|
||||
]);
|
||||
|
||||
final results = await query.get();
|
||||
// Сортируем вручную по order из junction table
|
||||
results.sort((a, b) {
|
||||
final orderA = a.read(db.previewCards.order) ?? 0;
|
||||
final orderB = b.read(db.previewCards.order) ?? 0;
|
||||
return orderA.compareTo(orderB);
|
||||
});
|
||||
|
||||
return results.map((row) => row.readTable(gameCards)).toList();
|
||||
}
|
||||
|
||||
/// Установить preview карточки для пака
|
||||
Future<void> setPreviewCards(int packId, List<int> cardIds) async {
|
||||
await transaction(() async {
|
||||
// Удалить старые preview карточки
|
||||
await (delete(previewCards)
|
||||
..where((pc) => pc.packId.equals(packId))
|
||||
).go();
|
||||
|
||||
// Добавить новые
|
||||
for (var i = 0; i < cardIds.length; i++) {
|
||||
await into(previewCards).insert(
|
||||
PreviewCardsCompanion.insert(
|
||||
packId: packId,
|
||||
cardId: cardIds[i],
|
||||
order: Value(i),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== VoiceModels ====================
|
||||
|
||||
/// Получить голосовые модели карточки
|
||||
Future<List<VoiceModel>> getCardVoices(int cardId) async {
|
||||
final query = select(voiceModels).join([
|
||||
innerJoin(
|
||||
cardVoices,
|
||||
cardVoices.voiceId.equalsExp(voiceModels.id) &
|
||||
cardVoices.cardId.equals(cardId),
|
||||
),
|
||||
]);
|
||||
|
||||
return query.map((row) => row.readTable(voiceModels)).get();
|
||||
}
|
||||
|
||||
/// Добавить голосовую модель к карточке
|
||||
Future<void> addVoiceToCard(int cardId, int voiceId) async {
|
||||
await into(cardVoices).insert(
|
||||
CardVoicesCompanion.insert(
|
||||
cardId: cardId,
|
||||
voiceId: voiceId,
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
/// Удалить голосовую модель из карточки
|
||||
Future<void> removeVoiceFromCard(int cardId, int voiceId) async {
|
||||
await (delete(cardVoices)
|
||||
..where((cv) => cv.cardId.equals(cardId) & cv.voiceId.equals(voiceId))
|
||||
).go();
|
||||
}
|
||||
|
||||
/// Создать голосовую модель
|
||||
Future<int> createVoice(VoiceModelsCompanion voice) {
|
||||
return into(voiceModels).insert(voice);
|
||||
}
|
||||
|
||||
/// Получить голосовую модель по ID
|
||||
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();
|
||||
}
|
||||
}
|
||||
13
mnemo_cards_backend/lib/database/daos/pack_dao.g.dart
Normal file
13
mnemo_cards_backend/lib/database/daos/pack_dao.g.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'pack_dao.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
mixin _$PackDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||
$CardPacksTable get cardPacks => attachedDatabase.cardPacks;
|
||||
$GameCardsTable get gameCards => attachedDatabase.gameCards;
|
||||
$VoiceModelsTable get voiceModels => attachedDatabase.voiceModels;
|
||||
$PreviewCardsTable get previewCards => attachedDatabase.previewCards;
|
||||
$CardPackCardsTable get cardPackCards => attachedDatabase.cardPackCards;
|
||||
$CardVoicesTable get cardVoices => attachedDatabase.cardVoices;
|
||||
}
|
||||
133
mnemo_cards_backend/lib/database/daos/payment_dao.dart
Normal file
133
mnemo_cards_backend/lib/database/daos/payment_dao.dart
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../database.dart';
|
||||
import '../tables/payments.dart';
|
||||
import '../tables/users.dart';
|
||||
|
||||
part 'payment_dao.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [Payments])
|
||||
class PaymentDao extends DatabaseAccessor<AppDatabase> with _$PaymentDaoMixin {
|
||||
PaymentDao(super.db);
|
||||
|
||||
/// Получить платеж по ID
|
||||
Future<Payment?> getPaymentById(int id) {
|
||||
return (select(payments)..where((p) => p.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить платежи пользователя
|
||||
Future<List<Payment>> getPaymentsByUserId(int userId, {
|
||||
int? limit,
|
||||
int? offset,
|
||||
}) {
|
||||
final query = select(payments)
|
||||
..where((p) => p.userId.equals(userId))
|
||||
..orderBy([(p) => OrderingTerm.desc(p.date)]);
|
||||
|
||||
if (limit != null) {
|
||||
query.limit(limit, offset: offset);
|
||||
}
|
||||
|
||||
return query.get();
|
||||
}
|
||||
|
||||
/// Получить платежи по статусу
|
||||
Future<List<Payment>> getPaymentsByStatus(String status) {
|
||||
return (select(payments)
|
||||
..where((p) => p.status.equals(status))
|
||||
..orderBy([(p) => OrderingTerm.desc(p.date)])
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Создать платеж
|
||||
Future<int> createPayment(PaymentsCompanion payment) {
|
||||
return into(payments).insert(payment);
|
||||
}
|
||||
|
||||
/// Обновить платеж
|
||||
Future<bool> updatePayment(Payment payment) {
|
||||
return update(payments).replace(payment);
|
||||
}
|
||||
|
||||
/// Обновить платеж частично
|
||||
Future<void> updatePaymentCompanion(PaymentsCompanion companion) {
|
||||
final paymentId = companion.id.value;
|
||||
if (paymentId == null) throw ArgumentError('Payment ID is required');
|
||||
|
||||
return (update(payments)..where((p) => p.id.equals(paymentId)))
|
||||
.write(companion);
|
||||
}
|
||||
|
||||
/// Обновить статус платежа
|
||||
Future<void> updatePaymentStatus(int paymentId, String status) {
|
||||
return (update(payments)..where((p) => p.id.equals(paymentId)))
|
||||
.write(PaymentsCompanion(
|
||||
status: Value(status),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
/// Подсчитать платежи пользователя
|
||||
Future<int> countPaymentsByUserId(int userId) async {
|
||||
final countExpr = payments.id.count();
|
||||
final query = selectOnly(payments)
|
||||
..addColumns([countExpr])
|
||||
..where(payments.userId.equals(userId));
|
||||
|
||||
return await query.map((row) => row.read(countExpr)!).getSingle();
|
||||
}
|
||||
|
||||
/// Получить платеж по externalToken
|
||||
Future<Payment?> getPaymentByExternalToken(String token) {
|
||||
return (select(payments)..where((p) => p.externalToken.equals(token)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить платежи по продукту (store ID)
|
||||
Future<List<Payment>> getPaymentsByProduct(String productId) async {
|
||||
// Поиск по продуктам в JSON массиве
|
||||
final query = select(payments)
|
||||
..where((p) => p.products.like('%$productId%'));
|
||||
|
||||
return query.get();
|
||||
}
|
||||
|
||||
/// Получить платежи по статусу с пагинацией
|
||||
Future<List<Payment>> getPaymentsByStatusPaged(
|
||||
String status, {
|
||||
int? limit,
|
||||
int? offset,
|
||||
}) {
|
||||
final query = select(payments)
|
||||
..where((p) => p.status.equals(status))
|
||||
..orderBy([(p) => OrderingTerm.desc(p.date)]);
|
||||
|
||||
if (limit != null) {
|
||||
query.limit(limit, offset: offset);
|
||||
}
|
||||
|
||||
return query.get();
|
||||
}
|
||||
|
||||
/// Получить все платежи (для админки)
|
||||
Future<List<Payment>> getAllPayments({
|
||||
int? limit,
|
||||
int? offset,
|
||||
}) {
|
||||
final query = select(payments)
|
||||
..orderBy([(p) => OrderingTerm.desc(p.date)]);
|
||||
|
||||
if (limit != null) {
|
||||
query.limit(limit, offset: offset);
|
||||
}
|
||||
|
||||
return query.get();
|
||||
}
|
||||
|
||||
/// Подсчитать все платежи
|
||||
Future<int> countAllPayments() async {
|
||||
final countExpr = payments.id.count();
|
||||
final query = selectOnly(payments)..addColumns([countExpr]);
|
||||
|
||||
return await query.map((row) => row.read(countExpr)!).getSingle();
|
||||
}
|
||||
}
|
||||
9
mnemo_cards_backend/lib/database/daos/payment_dao.g.dart
Normal file
9
mnemo_cards_backend/lib/database/daos/payment_dao.g.dart
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'payment_dao.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
mixin _$PaymentDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||
$UsersTable get users => attachedDatabase.users;
|
||||
$PaymentsTable get payments => attachedDatabase.payments;
|
||||
}
|
||||
91
mnemo_cards_backend/lib/database/daos/promo_code_dao.dart
Normal file
91
mnemo_cards_backend/lib/database/daos/promo_code_dao.dart
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../database.dart';
|
||||
import '../tables/promo_codes.dart';
|
||||
import '../tables/users.dart';
|
||||
|
||||
part 'promo_code_dao.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [PromoCodesCampaigns, PromoCodes])
|
||||
class PromoCodeDao extends DatabaseAccessor<AppDatabase> with _$PromoCodeDaoMixin {
|
||||
PromoCodeDao(super.db);
|
||||
|
||||
// ==================== PromoCodesCampaigns ====================
|
||||
|
||||
/// Получить кампанию по ID
|
||||
Future<PromoCodesCampaign?> getCampaignById(int id) {
|
||||
return (select(db.promoCodesCampaigns)..where((c) => c.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить все активные кампании
|
||||
Future<List<PromoCodesCampaign>> getActiveCampaigns() {
|
||||
final now = DateTime.now();
|
||||
return (select(db.promoCodesCampaigns)
|
||||
..where((c) => c.status.equals('active'))
|
||||
..where((c) => c.start.isSmallerOrEqualValue(now))
|
||||
..where((c) => c.finish.isBiggerOrEqualValue(now))
|
||||
..where((c) => c.isDeleted.equals(false))
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Создать кампанию
|
||||
Future<int> createCampaign(PromoCodesCampaignsCompanion campaign) {
|
||||
return into(db.promoCodesCampaigns).insert(campaign);
|
||||
}
|
||||
|
||||
/// Обновить кампанию
|
||||
Future<bool> updateCampaign(PromoCodesCampaign campaign) {
|
||||
return update(db.promoCodesCampaigns).replace(campaign);
|
||||
}
|
||||
|
||||
// ==================== PromoCodes ====================
|
||||
|
||||
/// Получить промокод по коду
|
||||
Future<PromoCode?> getPromoCodeByCode(String code) {
|
||||
return (select(db.promoCodes)
|
||||
..where((pc) => pc.code.equals(code))
|
||||
).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить промокоды кампании
|
||||
Future<List<PromoCode>> getPromoCodesByCampaignId(int campaignId) {
|
||||
return (select(db.promoCodes)
|
||||
..where((pc) => pc.campaignId.equals(campaignId))
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Получить индивидуальные промокоды пользователя
|
||||
Future<List<PromoCode>> getUserPromoCodes(int userId) {
|
||||
return (select(db.promoCodes)
|
||||
..where((pc) => pc.userId.equals(userId))
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Создать промокод
|
||||
Future<int> createPromoCode(PromoCodesCompanion promoCode) {
|
||||
return into(db.promoCodes).insert(promoCode);
|
||||
}
|
||||
|
||||
/// Обновить промокод
|
||||
Future<bool> updatePromoCode(PromoCode promoCode) {
|
||||
return update(db.promoCodes).replace(promoCode);
|
||||
}
|
||||
|
||||
/// Увеличить счетчик активаций
|
||||
Future<void> incrementActivations(int promoCodeId) async {
|
||||
final code = await (select(db.promoCodes)
|
||||
..where((pc) => pc.id.equals(promoCodeId))
|
||||
).getSingleOrNull();
|
||||
|
||||
if (code != null) {
|
||||
await update(db.promoCodes).replace(code.copyWith(
|
||||
activations: code.activations + 1,
|
||||
updatedAt: DateTime.now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Удалить промокод
|
||||
Future<void> deletePromoCode(int promoCodeId) {
|
||||
return (delete(db.promoCodes)..where((pc) => pc.id.equals(promoCodeId))).go();
|
||||
}
|
||||
}
|
||||
11
mnemo_cards_backend/lib/database/daos/promo_code_dao.g.dart
Normal file
11
mnemo_cards_backend/lib/database/daos/promo_code_dao.g.dart
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'promo_code_dao.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
mixin _$PromoCodeDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||
$PromoCodesCampaignsTable get promoCodesCampaigns =>
|
||||
attachedDatabase.promoCodesCampaigns;
|
||||
$UsersTable get users => attachedDatabase.users;
|
||||
$PromoCodesTable get promoCodes => attachedDatabase.promoCodes;
|
||||
}
|
||||
96
mnemo_cards_backend/lib/database/daos/statistics_dao.dart
Normal file
96
mnemo_cards_backend/lib/database/daos/statistics_dao.dart
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../database.dart';
|
||||
import '../tables/statistics.dart';
|
||||
import '../tables/users.dart';
|
||||
|
||||
part 'statistics_dao.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [StudySessions])
|
||||
class StatisticsDao extends DatabaseAccessor<AppDatabase> with _$StatisticsDaoMixin {
|
||||
StatisticsDao(super.db);
|
||||
|
||||
/// Получить сессию по ID
|
||||
Future<StudySession?> getSessionById(int id) {
|
||||
return (select(studySessions)..where((s) => s.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить сессию по sessionId
|
||||
Future<StudySession?> getSessionBySessionId(String sessionId) {
|
||||
return (select(studySessions)
|
||||
..where((s) => s.sessionId.equals(sessionId))
|
||||
).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить активные сессии пользователя
|
||||
Future<List<StudySession>> getActiveSessions(int userId) {
|
||||
return (select(studySessions)
|
||||
..where((s) => s.userId.equals(userId))
|
||||
..where((s) => s.endTime.isNull())
|
||||
..orderBy([(s) => OrderingTerm.desc(s.startTime)])
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Получить сессии пользователя
|
||||
Future<List<StudySession>> getSessionsByUserId(int userId, {
|
||||
int? limit,
|
||||
int? offset,
|
||||
DateTime? fromDate,
|
||||
DateTime? toDate,
|
||||
}) {
|
||||
final query = select(studySessions)
|
||||
..where((s) => s.userId.equals(userId))
|
||||
..orderBy([(s) => OrderingTerm.desc(s.startTime)]);
|
||||
|
||||
if (fromDate != null) {
|
||||
query.where((s) => s.startTime.isBiggerOrEqualValue(fromDate));
|
||||
}
|
||||
if (toDate != null) {
|
||||
query.where((s) => s.startTime.isSmallerOrEqualValue(toDate));
|
||||
}
|
||||
|
||||
if (limit != null) {
|
||||
query.limit(limit, offset: offset);
|
||||
}
|
||||
|
||||
return query.get();
|
||||
}
|
||||
|
||||
/// Создать сессию
|
||||
Future<int> createSession(StudySessionsCompanion session) {
|
||||
return into(studySessions).insert(session);
|
||||
}
|
||||
|
||||
/// Обновить сессию
|
||||
Future<bool> updateSession(StudySession session) {
|
||||
return update(studySessions).replace(session);
|
||||
}
|
||||
|
||||
/// Завершить сессию
|
||||
Future<void> endSession(int sessionId, {
|
||||
int? wordsLearned,
|
||||
int? testsCompleted,
|
||||
double? accuracy,
|
||||
}) {
|
||||
final updates = StudySessionsCompanion(
|
||||
id: Value(sessionId),
|
||||
endTime: Value(DateTime.now()),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
wordsLearned: wordsLearned != null ? Value(wordsLearned) : const Value.absent(),
|
||||
testsCompleted: testsCompleted != null ? Value(testsCompleted) : const Value.absent(),
|
||||
accuracy: accuracy != null ? Value(accuracy) : const Value.absent(),
|
||||
);
|
||||
|
||||
return (update(studySessions)..where((s) => s.id.equals(sessionId)))
|
||||
.write(updates);
|
||||
}
|
||||
|
||||
/// Подсчитать сессии пользователя
|
||||
Future<int> countSessionsByUserId(int userId) async {
|
||||
final countExpr = studySessions.id.count();
|
||||
final query = selectOnly(studySessions)
|
||||
..addColumns([countExpr])
|
||||
..where(studySessions.userId.equals(userId));
|
||||
|
||||
return await query.map((row) => row.read(countExpr)!).getSingle();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'statistics_dao.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
mixin _$StatisticsDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||
$UsersTable get users => attachedDatabase.users;
|
||||
$StudySessionsTable get studySessions => attachedDatabase.studySessions;
|
||||
}
|
||||
89
mnemo_cards_backend/lib/database/daos/subscription_dao.dart
Normal file
89
mnemo_cards_backend/lib/database/daos/subscription_dao.dart
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../database.dart';
|
||||
import '../tables/subscriptions.dart';
|
||||
import '../tables/users.dart';
|
||||
|
||||
part 'subscription_dao.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [SubscriptionPlans, UserSubscriptions])
|
||||
class SubscriptionDao extends DatabaseAccessor<AppDatabase> with _$SubscriptionDaoMixin {
|
||||
SubscriptionDao(super.db);
|
||||
|
||||
// ==================== SubscriptionPlans ====================
|
||||
|
||||
/// Получить план подписки по ID
|
||||
Future<SubscriptionPlan?> getPlanById(int id) {
|
||||
return (select(subscriptionPlans)..where((p) => p.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить все планы подписки
|
||||
Future<List<SubscriptionPlan>> getAllPlans() {
|
||||
return (select(subscriptionPlans)
|
||||
..where((p) => p.isDeleted.equals(false))
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Создать план подписки
|
||||
Future<int> createPlan(SubscriptionPlansCompanion plan) {
|
||||
return into(subscriptionPlans).insert(plan);
|
||||
}
|
||||
|
||||
/// Обновить план подписки
|
||||
Future<bool> updatePlan(SubscriptionPlan plan) {
|
||||
return update(subscriptionPlans).replace(plan);
|
||||
}
|
||||
|
||||
// ==================== UserSubscriptions ====================
|
||||
|
||||
/// Получить подписку пользователя
|
||||
Future<UserSubscription?> getUserSubscription(int userId) {
|
||||
return (select(userSubscriptions)
|
||||
..where((us) => us.userId.equals(userId))
|
||||
..orderBy([(us) => OrderingTerm.desc(us.finish)])
|
||||
).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить активную подписку пользователя
|
||||
Future<UserSubscription?> getActiveSubscription(int userId) async {
|
||||
final now = DateTime.now();
|
||||
return (select(userSubscriptions)
|
||||
..where((us) => us.userId.equals(userId))
|
||||
..where((us) => us.start.isSmallerThanValue(now))
|
||||
..where((us) => us.finish.isBiggerThanValue(now))
|
||||
..orderBy([(us) => OrderingTerm.desc(us.finish)])
|
||||
).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Проверить, есть ли у пользователя активная подписка
|
||||
Future<bool> hasActiveSubscription(int userId) async {
|
||||
final subscription = await getActiveSubscription(userId);
|
||||
return subscription != null;
|
||||
}
|
||||
|
||||
/// Создать подписку пользователя
|
||||
Future<int> createUserSubscription(UserSubscriptionsCompanion subscription) {
|
||||
return into(userSubscriptions).insert(subscription);
|
||||
}
|
||||
|
||||
/// Обновить подписку пользователя
|
||||
Future<bool> updateUserSubscription(UserSubscription subscription) {
|
||||
return update(userSubscriptions).replace(subscription);
|
||||
}
|
||||
|
||||
/// Удалить подписку пользователя
|
||||
Future<void> deleteUserSubscription(int userId) {
|
||||
return (delete(userSubscriptions)
|
||||
..where((us) => us.userId.equals(userId))
|
||||
).go();
|
||||
}
|
||||
|
||||
/// Получить всех пользователей с активными подписками
|
||||
Future<List<UserSubscription>> getActiveSubscriptions() async {
|
||||
final now = DateTime.now();
|
||||
return (select(userSubscriptions)
|
||||
..where((us) => us.start.isSmallerThanValue(now))
|
||||
..where((us) => us.finish.isBiggerThanValue(now))
|
||||
..orderBy([(us) => OrderingTerm.desc(us.finish)])
|
||||
).get();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'subscription_dao.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
mixin _$SubscriptionDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||
$SubscriptionPlansTable get subscriptionPlans =>
|
||||
attachedDatabase.subscriptionPlans;
|
||||
$UsersTable get users => attachedDatabase.users;
|
||||
$UserSubscriptionsTable get userSubscriptions =>
|
||||
attachedDatabase.userSubscriptions;
|
||||
}
|
||||
128
mnemo_cards_backend/lib/database/daos/task_dao.dart
Normal file
128
mnemo_cards_backend/lib/database/daos/task_dao.dart
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../database.dart';
|
||||
// import '../tables/tasks.dart'; // Causes conflict, using database.dart instead
|
||||
import '../tables/users.dart';
|
||||
|
||||
part 'task_dao.g.dart';
|
||||
|
||||
@DriftAccessor()
|
||||
class TaskDao extends DatabaseAccessor<AppDatabase> with _$TaskDaoMixin {
|
||||
TaskDao(super.db);
|
||||
|
||||
// ==================== Tasks ====================
|
||||
|
||||
/// Получить задачу по ID
|
||||
Future<Task?> getTaskById(int id) {
|
||||
return (select(db.tasks)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить все задачи
|
||||
Future<List<Task>> getAllTasks() {
|
||||
return select(db.tasks).get();
|
||||
}
|
||||
|
||||
/// Создать задачу
|
||||
Future<int> createTask(TasksCompanion task) {
|
||||
return into(db.tasks).insert(task);
|
||||
}
|
||||
|
||||
/// Обновить задачу
|
||||
Future<bool> updateTask(Task task) {
|
||||
return update(db.tasks).replace(task);
|
||||
}
|
||||
|
||||
/// Обновить время последнего выполнения
|
||||
Future<void> updateLastExecution(int taskId) {
|
||||
return (update(db.tasks)..where((t) => t.id.equals(taskId)))
|
||||
.write(TasksCompanion(
|
||||
lastExecution: Value(DateTime.now()),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== UserTasks ====================
|
||||
|
||||
/// Получить задачу пользователя по ID
|
||||
Future<UserTask?> getUserTaskById(int id) {
|
||||
return (select(db.userTasks)..where((ut) => ut.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить задачи пользователя
|
||||
/// 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) {
|
||||
query.where((ut) => ut.status.equals(status));
|
||||
}
|
||||
|
||||
if (activeOnly) {
|
||||
final now = DateTime.now();
|
||||
query.where((ut) => ut.expiresAt.isBiggerThanValue(now));
|
||||
}
|
||||
|
||||
query.orderBy([(ut) => OrderingTerm.desc(ut.createdAt)]);
|
||||
|
||||
return query.get();
|
||||
}
|
||||
|
||||
/// Создать задачу пользователя
|
||||
Future<int> createUserTask(UserTasksCompanion task) {
|
||||
return into(db.userTasks).insert(task);
|
||||
}
|
||||
|
||||
/// Обновить задачу пользователя
|
||||
Future<bool> updateUserTask(UserTask task) {
|
||||
return update(db.userTasks).replace(task);
|
||||
}
|
||||
|
||||
/// Завершить задачу пользователя
|
||||
Future<void> completeUserTask(int taskId) {
|
||||
return (update(db.userTasks)..where((ut) => ut.id.equals(taskId)))
|
||||
.write(UserTasksCompanion(
|
||||
status: const Value('completed'),
|
||||
completedAt: Value(DateTime.now()),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== UserTaskProgresses ====================
|
||||
|
||||
/// Получить прогресс задачи пользователя
|
||||
Future<UserTaskProgresses?> getTaskProgress(int userId, int taskId) {
|
||||
return (select(db.userTaskProgresses)
|
||||
..where((utp) => utp.userId.equals(userId) & utp.taskId.equals(taskId))
|
||||
).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Создать прогресс задачи
|
||||
Future<int> createTaskProgress(UserTaskProgressesCompanion progress) {
|
||||
return into(db.userTaskProgresses).insert(progress);
|
||||
}
|
||||
|
||||
/// Обновить прогресс задачи
|
||||
Future<bool> updateTaskProgress(UserTaskProgresses progress) {
|
||||
return update(db.userTaskProgresses).replace(progress);
|
||||
}
|
||||
|
||||
// ==================== UserTaskResults ====================
|
||||
|
||||
/// Получить результаты задач пользователя
|
||||
Future<List<UserTaskResult>> getTaskResults(int userId) {
|
||||
return (select(db.userTaskResults)
|
||||
..where((utr) => utr.userId.equals(userId))
|
||||
..orderBy([(utr) => OrderingTerm.desc(utr.completedAt)])
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Создать результат задачи
|
||||
Future<int> createTaskResult(UserTaskResultsCompanion result) {
|
||||
return into(db.userTaskResults).insert(result);
|
||||
}
|
||||
}
|
||||
6
mnemo_cards_backend/lib/database/daos/task_dao.g.dart
Normal file
6
mnemo_cards_backend/lib/database/daos/task_dao.g.dart
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'task_dao.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
mixin _$TaskDaoMixin on DatabaseAccessor<AppDatabase> {}
|
||||
111
mnemo_cards_backend/lib/database/daos/test_dao.dart
Normal file
111
mnemo_cards_backend/lib/database/daos/test_dao.dart
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../database.dart';
|
||||
import '../tables/tests.dart';
|
||||
import '../tables/packs.dart';
|
||||
|
||||
part 'test_dao.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [Tests, TestQuestions, TestPackRelations, TestStatistics])
|
||||
class TestDao extends DatabaseAccessor<AppDatabase> with _$TestDaoMixin {
|
||||
TestDao(super.db);
|
||||
|
||||
// ==================== Tests ====================
|
||||
|
||||
/// Получить тест по ID
|
||||
Future<Test?> getTestById(int id) {
|
||||
return (select(tests)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить все тесты
|
||||
Future<List<Test>> getAllTests() {
|
||||
return (select(tests)
|
||||
..where((t) => t.isDeleted.equals(false))
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Получить тесты пака
|
||||
Future<List<Test>> getTestsByPackId(int packId) async {
|
||||
final query = select(tests).join([
|
||||
innerJoin(
|
||||
testPackRelations,
|
||||
testPackRelations.testId.equalsExp(tests.id) &
|
||||
testPackRelations.packId.equals(packId),
|
||||
),
|
||||
]);
|
||||
|
||||
return query.map((row) => row.readTable(tests)).get();
|
||||
}
|
||||
|
||||
/// Создать тест
|
||||
Future<int> createTest(TestsCompanion test) {
|
||||
return into(tests).insert(test);
|
||||
}
|
||||
|
||||
/// Обновить тест
|
||||
Future<bool> updateTest(Test test) {
|
||||
return update(tests).replace(test);
|
||||
}
|
||||
|
||||
/// Удалить тест (soft delete)
|
||||
Future<void> softDeleteTest(int testId) {
|
||||
return (update(tests)..where((t) => t.id.equals(testId)))
|
||||
.write(TestsCompanion(
|
||||
isDeleted: const Value(true),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
/// Связать тест с паком
|
||||
Future<void> linkTestToPack(int testId, int packId) async {
|
||||
await into(testPackRelations).insert(
|
||||
TestPackRelationsCompanion.insert(
|
||||
testId: testId,
|
||||
packId: packId,
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== TestQuestions ====================
|
||||
|
||||
/// Получить вопросы теста
|
||||
Future<List<TestQuestion>> getTestQuestions(int testId) {
|
||||
return (select(testQuestions)
|
||||
..where((tq) => tq.testId.equals(testId))
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Создать вопрос теста
|
||||
Future<int> createTestQuestion(TestQuestionsCompanion question) {
|
||||
return into(testQuestions).insert(question);
|
||||
}
|
||||
|
||||
/// Обновить вопрос теста
|
||||
Future<bool> updateTestQuestion(TestQuestion question) {
|
||||
return update(testQuestions).replace(question);
|
||||
}
|
||||
|
||||
/// Удалить вопрос теста
|
||||
Future<void> deleteTestQuestion(int questionId) {
|
||||
return (delete(testQuestions)..where((tq) => tq.id.equals(questionId))).go();
|
||||
}
|
||||
|
||||
// ==================== TestStatistics ====================
|
||||
|
||||
/// Получить статистику теста пользователя
|
||||
Future<TestStatistic?> getTestStatistics(int userId, int testId) {
|
||||
return (select(testStatistics)
|
||||
..where((ts) => ts.userId.equals(userId) & ts.testId.equals(testId))
|
||||
).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Создать статистику теста
|
||||
Future<int> createTestStatistics(TestStatisticsCompanion statistics) {
|
||||
return into(testStatistics).insert(statistics);
|
||||
}
|
||||
|
||||
/// Обновить статистику теста
|
||||
Future<bool> updateTestStatistics(TestStatistic statistics) {
|
||||
return update(testStatistics).replace(statistics);
|
||||
}
|
||||
}
|
||||
13
mnemo_cards_backend/lib/database/daos/test_dao.g.dart
Normal file
13
mnemo_cards_backend/lib/database/daos/test_dao.g.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'test_dao.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
mixin _$TestDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||
$TestsTable get tests => attachedDatabase.tests;
|
||||
$TestQuestionsTable get testQuestions => attachedDatabase.testQuestions;
|
||||
$CardPacksTable get cardPacks => attachedDatabase.cardPacks;
|
||||
$TestPackRelationsTable get testPackRelations =>
|
||||
attachedDatabase.testPackRelations;
|
||||
$TestStatisticsTable get testStatistics => attachedDatabase.testStatistics;
|
||||
}
|
||||
326
mnemo_cards_backend/lib/database/daos/user_dao.dart
Normal file
326
mnemo_cards_backend/lib/database/daos/user_dao.dart
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../database.dart';
|
||||
import '../tables/users.dart';
|
||||
import '../tables/auth.dart';
|
||||
import '../tables/packs.dart';
|
||||
import '../tables/relations.dart';
|
||||
|
||||
part 'user_dao.g.dart';
|
||||
|
||||
@DriftAccessor(tables: [Users, UserDatas, Tokens, RefreshTokens, UserPacks])
|
||||
class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
|
||||
UserDao(super.db);
|
||||
|
||||
// ==================== Users ====================
|
||||
|
||||
/// Получить пользователя по ID
|
||||
Future<User?> getUserById(int id) {
|
||||
return (select(users)..where((u) => u.id.equals(id))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить пользователя с UserData
|
||||
Future<UserWithData?> getUserWithDataById(int id) async {
|
||||
final query = select(users).join([
|
||||
leftOuterJoin(userDatas, userDatas.userId.equalsExp(users.id)),
|
||||
])..where(users.id.equals(id));
|
||||
|
||||
final result = await query.getSingleOrNull();
|
||||
if (result == null) return null;
|
||||
|
||||
return UserWithData(
|
||||
user: result.readTable(users),
|
||||
userData: result.readTableOrNull(userDatas),
|
||||
);
|
||||
}
|
||||
|
||||
/// Получить пользователя по email
|
||||
Future<User?> getUserByEmail(String email) {
|
||||
return (select(users)..where((u) => u.email.equals(email))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить пользователя по externalUserId
|
||||
Future<User?> getUserByExternalId(String externalId) {
|
||||
return (select(users)
|
||||
..where((u) => u.externalUserId.equals(externalId))
|
||||
).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Создать пользователя
|
||||
Future<int> createUser(UsersCompanion user) {
|
||||
return into(users).insert(user);
|
||||
}
|
||||
|
||||
/// Создать пользователя с UserData
|
||||
Future<int> createUserWithData({
|
||||
required UsersCompanion user,
|
||||
required UserDatasCompanion userData,
|
||||
}) async {
|
||||
return await transaction(() async {
|
||||
final userId = await into(users).insert(user);
|
||||
await into(userDatas).insert(userData.copyWith(userId: Value(userId)));
|
||||
return userId;
|
||||
});
|
||||
}
|
||||
|
||||
/// Обновить пользователя
|
||||
Future<bool> updateUser(User user) {
|
||||
return update(users).replace(user);
|
||||
}
|
||||
|
||||
/// Обновить пользователя частично
|
||||
Future<void> updateUserPartial(UsersCompanion updates) {
|
||||
final userId = updates.id.value;
|
||||
if (userId == null) throw ArgumentError('User ID is required');
|
||||
|
||||
return (update(users)..where((u) => u.id.equals(userId))).write(updates);
|
||||
}
|
||||
|
||||
/// Удалить пользователя (soft delete)
|
||||
Future<void> softDeleteUser(int userId) {
|
||||
return (update(users)..where((u) => u.id.equals(userId)))
|
||||
.write(UsersCompanion(
|
||||
isDeleted: const Value(true),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
/// Получить всех пользователей (для админки)
|
||||
Future<List<User>> getAllUsers({
|
||||
int? limit,
|
||||
int? offset,
|
||||
bool includeDeleted = false,
|
||||
}) {
|
||||
final query = select(users);
|
||||
if (!includeDeleted) {
|
||||
query.where((u) => u.isDeleted.equals(false));
|
||||
}
|
||||
query.orderBy([(u) => OrderingTerm.desc(u.createdAt)]);
|
||||
if (limit != null) {
|
||||
query.limit(limit, offset: offset);
|
||||
}
|
||||
return query.get();
|
||||
}
|
||||
|
||||
/// Подсчитать пользователей
|
||||
Future<int> countUsers({bool includeDeleted = false}) async {
|
||||
final countExpr = users.id.count();
|
||||
final query = selectOnly(users)..addColumns([countExpr]);
|
||||
if (!includeDeleted) {
|
||||
query.where(users.isDeleted.equals(false));
|
||||
}
|
||||
return await query.map((row) => row.read(countExpr)!).getSingle();
|
||||
}
|
||||
|
||||
/// Получить пользователей с активной подпиской
|
||||
Stream<List<User>> watchUsersWithActiveSubscription() {
|
||||
// TODO: implement with join to UserSubscriptions when SubscriptionDao is ready
|
||||
return (select(users)
|
||||
..where((u) => u.isDeleted.equals(false))
|
||||
).watch();
|
||||
}
|
||||
|
||||
// ==================== UserData ====================
|
||||
|
||||
/// Получить UserData пользователя
|
||||
Future<UserData?> getUserData(int userId) {
|
||||
return (select(userDatas)..where((ud) => ud.userId.equals(userId)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Создать UserData
|
||||
Future<int> createUserData(UserDatasCompanion userData) {
|
||||
return into(userDatas).insert(userData);
|
||||
}
|
||||
|
||||
/// Обновить UserData
|
||||
Future<bool> updateUserData(UserData userData) {
|
||||
return update(userDatas).replace(userData);
|
||||
}
|
||||
|
||||
/// Обновить UserData частично
|
||||
Future<void> updateUserDataPartial(UserDatasCompanion updates) {
|
||||
final userId = updates.userId.value;
|
||||
if (userId == null) throw ArgumentError('User ID is required');
|
||||
|
||||
return (update(userDatas)..where((ud) => ud.userId.equals(userId)))
|
||||
.write(updates.copyWith(updatedAt: Value(DateTime.now())));
|
||||
}
|
||||
|
||||
/// Обновить время последнего визита
|
||||
Future<void> updateLastOnline(int userId) async {
|
||||
await (update(userDatas)..where((ud) => ud.userId.equals(userId)))
|
||||
.write(UserDatasCompanion(
|
||||
lastTimeOnline: Value(DateTime.now()),
|
||||
updatedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== Tokens ====================
|
||||
|
||||
/// Получить токен по значению
|
||||
Future<Token?> getTokenByValue(String tokenValue) {
|
||||
return (select(tokens)..where((t) => t.token.equals(tokenValue)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить токен пользователя
|
||||
Future<Token?> getTokenByUserId(int userId) {
|
||||
return (select(tokens)
|
||||
..where((t) => t.userId.equals(userId))
|
||||
..where((t) => t.expires.isBiggerThanValue(DateTime.now()))
|
||||
..orderBy([(t) => OrderingTerm.desc(t.created)])
|
||||
).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Создать токен
|
||||
Future<int> createToken(TokensCompanion token) {
|
||||
return into(tokens).insert(token);
|
||||
}
|
||||
|
||||
/// Удалить токен
|
||||
Future<int> deleteToken(int tokenId) {
|
||||
return (delete(tokens)..where((t) => t.id.equals(tokenId))).go();
|
||||
}
|
||||
|
||||
/// Удалить токен по значению
|
||||
Future<int> deleteTokenByValue(String tokenValue) {
|
||||
return (delete(tokens)..where((t) => t.token.equals(tokenValue))).go();
|
||||
}
|
||||
|
||||
/// Удалить истекшие токены
|
||||
Future<int> deleteExpiredTokens() {
|
||||
return (delete(tokens)
|
||||
..where((t) => t.expires.isSmallerThanValue(DateTime.now()))
|
||||
).go();
|
||||
}
|
||||
|
||||
// ==================== RefreshTokens ====================
|
||||
|
||||
/// Получить refresh token по JTI
|
||||
Future<RefreshToken?> getRefreshTokenByJti(String jti) {
|
||||
return (select(refreshTokens)..where((rt) => rt.jti.equals(jti)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Получить активные refresh токены пользователя
|
||||
Future<List<RefreshToken>> getActiveRefreshTokens(int userId) {
|
||||
return (select(refreshTokens)
|
||||
..where((rt) => rt.userId.equals(userId))
|
||||
..where((rt) => rt.isBlacklisted.equals(false))
|
||||
..where((rt) => rt.expiresAt.isBiggerThanValue(DateTime.now()))
|
||||
..orderBy([(rt) => OrderingTerm.desc(rt.createdAt)])
|
||||
).get();
|
||||
}
|
||||
|
||||
/// Создать refresh token
|
||||
Future<int> createRefreshToken(RefreshTokensCompanion token) {
|
||||
return into(refreshTokens).insert(token);
|
||||
}
|
||||
|
||||
/// Отозвать refresh token (blacklist)
|
||||
Future<void> revokeRefreshToken(int tokenId) {
|
||||
return (update(refreshTokens)..where((rt) => rt.id.equals(tokenId)))
|
||||
.write(const RefreshTokensCompanion(
|
||||
isBlacklisted: Value(true),
|
||||
));
|
||||
}
|
||||
|
||||
/// Отозвать refresh token по JTI
|
||||
Future<void> revokeRefreshTokenByJti(String jti) {
|
||||
return (update(refreshTokens)..where((rt) => rt.jti.equals(jti)))
|
||||
.write(const RefreshTokensCompanion(
|
||||
isBlacklisted: Value(true),
|
||||
));
|
||||
}
|
||||
|
||||
/// Удалить истекшие refresh токены
|
||||
Future<int> deleteExpiredRefreshTokens() {
|
||||
return (delete(refreshTokens)
|
||||
..where((rt) => rt.expiresAt.isSmallerThanValue(DateTime.now()))
|
||||
).go();
|
||||
}
|
||||
|
||||
// ==================== TelegramAuthCodes ====================
|
||||
|
||||
/// Получить код авторизации
|
||||
Future<TelegramAuthCode?> getAuthCode(String code) {
|
||||
return (select(db.telegramAuthCodes)..where((ac) => ac.code.equals(code)))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Создать код авторизации
|
||||
Future<int> createAuthCode(TelegramAuthCodesCompanion code) {
|
||||
return into(db.telegramAuthCodes).insert(code);
|
||||
}
|
||||
|
||||
/// Отметить код как использованный
|
||||
Future<void> markAuthCodeAsUsed(String code) {
|
||||
return (update(db.telegramAuthCodes)..where((ac) => ac.code.equals(code)))
|
||||
.write(TelegramAuthCodesCompanion(
|
||||
isUsed: const Value(true),
|
||||
usedAt: Value(DateTime.now()),
|
||||
));
|
||||
}
|
||||
|
||||
/// Удалить истекшие коды
|
||||
Future<int> deleteExpiredAuthCodes() {
|
||||
return (delete(db.telegramAuthCodes)
|
||||
..where((ac) => ac.expiresAt.isSmallerThanValue(DateTime.now()))
|
||||
).go();
|
||||
}
|
||||
|
||||
// ==================== User Packs ====================
|
||||
|
||||
/// Получить паки пользователя
|
||||
Future<List<CardPack>> getUserPacks(int userId) async {
|
||||
final query = select(cardPacks).join([
|
||||
innerJoin(
|
||||
userPacks,
|
||||
userPacks.packId.equalsExp(cardPacks.id) &
|
||||
userPacks.userId.equals(userId),
|
||||
),
|
||||
]);
|
||||
|
||||
return query.map((row) => row.readTable(cardPacks)).get();
|
||||
}
|
||||
|
||||
/// Проверить, есть ли у пользователя доступ к паку
|
||||
Future<bool> hasPackAccess(int userId, int packId) async {
|
||||
final query = select(userPacks)
|
||||
..where((up) => up.userId.equals(userId) & up.packId.equals(packId));
|
||||
|
||||
final result = await query.getSingleOrNull();
|
||||
return result != null;
|
||||
}
|
||||
|
||||
/// Дать пользователю доступ к паку
|
||||
Future<void> grantPackAccess({
|
||||
required int userId,
|
||||
required int packId,
|
||||
String grantType = 'purchase',
|
||||
}) async {
|
||||
await into(userPacks).insert(
|
||||
UserPacksCompanion.insert(
|
||||
userId: userId,
|
||||
packId: packId,
|
||||
grantType: Value(grantType),
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore, // игнорировать если уже есть
|
||||
);
|
||||
}
|
||||
|
||||
/// Отозвать доступ к паку
|
||||
Future<void> revokePackAccess(int userId, int packId) async {
|
||||
await (delete(userPacks)
|
||||
..where((up) => up.userId.equals(userId) & up.packId.equals(packId))
|
||||
).go();
|
||||
}
|
||||
}
|
||||
|
||||
/// Вспомогательный класс для User с UserData
|
||||
class UserWithData {
|
||||
final User user;
|
||||
final UserData? userData;
|
||||
|
||||
UserWithData({required this.user, this.userData});
|
||||
}
|
||||
13
mnemo_cards_backend/lib/database/daos/user_dao.g.dart
Normal file
13
mnemo_cards_backend/lib/database/daos/user_dao.g.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'user_dao.dart';
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
mixin _$UserDaoMixin on DatabaseAccessor<AppDatabase> {
|
||||
$UsersTable get users => attachedDatabase.users;
|
||||
$UserDatasTable get userDatas => attachedDatabase.userDatas;
|
||||
$TokensTable get tokens => attachedDatabase.tokens;
|
||||
$RefreshTokensTable get refreshTokens => attachedDatabase.refreshTokens;
|
||||
$CardPacksTable get cardPacks => attachedDatabase.cardPacks;
|
||||
$UserPacksTable get userPacks => attachedDatabase.userPacks;
|
||||
}
|
||||
242
mnemo_cards_backend/lib/database/database.dart
Normal file
242
mnemo_cards_backend/lib/database/database.dart
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_postgres/drift_postgres.dart';
|
||||
import 'package:postgres/postgres.dart' as pg;
|
||||
import 'dart:io';
|
||||
|
||||
// Импорт конвертеров (нужен для генерации кода)
|
||||
import 'converters.dart';
|
||||
|
||||
// Импорт всех таблиц
|
||||
import 'tables/users.dart';
|
||||
import 'tables/auth.dart';
|
||||
import 'tables/packs.dart';
|
||||
import 'tables/relations.dart';
|
||||
import 'tables/subscriptions.dart';
|
||||
import 'tables/payments.dart';
|
||||
import 'tables/tests.dart';
|
||||
import 'tables/tasks.dart';
|
||||
import 'tables/promo_codes.dart';
|
||||
import 'tables/discounts.dart';
|
||||
import 'tables/statistics.dart';
|
||||
import 'tables/telegram.dart';
|
||||
import 'tables/achievements.dart';
|
||||
|
||||
// Импорт DAOs
|
||||
import 'daos/user_dao.dart';
|
||||
import 'daos/pack_dao.dart';
|
||||
import 'daos/test_dao.dart';
|
||||
import 'daos/payment_dao.dart';
|
||||
import 'daos/subscription_dao.dart';
|
||||
import 'daos/task_dao.dart';
|
||||
import 'daos/promo_code_dao.dart';
|
||||
import 'daos/discount_dao.dart';
|
||||
import 'daos/statistics_dao.dart';
|
||||
import 'daos/achievement_dao.dart';
|
||||
|
||||
// Сгенерированный код будет здесь
|
||||
part 'database.g.dart';
|
||||
|
||||
@DriftDatabase(
|
||||
tables: [
|
||||
// User tables
|
||||
Users,
|
||||
UserDatas,
|
||||
|
||||
// Auth tables
|
||||
Tokens,
|
||||
RefreshTokens,
|
||||
TelegramAuthCodes,
|
||||
|
||||
// Pack tables
|
||||
CardPacks,
|
||||
GameCards,
|
||||
VoiceModels,
|
||||
|
||||
// Relations
|
||||
UserPacks,
|
||||
PreviewCards,
|
||||
CardPackCards,
|
||||
CardVoices,
|
||||
|
||||
// Subscription tables
|
||||
SubscriptionPlans,
|
||||
UserSubscriptions,
|
||||
|
||||
// Payment tables
|
||||
Payments,
|
||||
|
||||
// Test tables
|
||||
Tests,
|
||||
TestQuestions,
|
||||
TestPackRelations,
|
||||
TestStatistics,
|
||||
|
||||
// Task tables
|
||||
Tasks,
|
||||
UserTasks,
|
||||
UserTaskProgresses,
|
||||
UserTaskResults,
|
||||
|
||||
// Promo code tables
|
||||
PromoCodesCampaigns,
|
||||
PromoCodes,
|
||||
|
||||
// Discount tables
|
||||
DiscountCampaigns,
|
||||
Discounts,
|
||||
DiscountUserDatas,
|
||||
|
||||
// Statistics tables
|
||||
StudySessions,
|
||||
|
||||
// Achievements tables
|
||||
UserAchievements,
|
||||
|
||||
// Telegram tables
|
||||
ShareRequests,
|
||||
],
|
||||
daos: [
|
||||
UserDao,
|
||||
PackDao,
|
||||
TestDao,
|
||||
PaymentDao,
|
||||
SubscriptionDao,
|
||||
TaskDao,
|
||||
PromoCodeDao,
|
||||
DiscountDao,
|
||||
StatisticsDao,
|
||||
AchievementDao,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase(super.e);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
|
||||
/// Factory для подключения к PostgreSQL
|
||||
static AppDatabase connect({
|
||||
required String host,
|
||||
required int port,
|
||||
required String database,
|
||||
required String username,
|
||||
required String password,
|
||||
bool useSsl = false,
|
||||
}) {
|
||||
final endpoint = pg.Endpoint(
|
||||
host: host,
|
||||
port: port,
|
||||
database: database,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
|
||||
final connection = PgDatabase(
|
||||
endpoint: endpoint,
|
||||
settings: pg.ConnectionSettings(
|
||||
sslMode: useSsl ? pg.SslMode.require : pg.SslMode.disable,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
|
||||
return AppDatabase(connection);
|
||||
}
|
||||
|
||||
/// Factory для подключения из environment variables
|
||||
static AppDatabase fromEnvironment() {
|
||||
return connect(
|
||||
host: Platform.environment['DB_HOST'] ?? 'localhost',
|
||||
port: int.parse(Platform.environment['DB_PORT'] ?? '5432'),
|
||||
database: Platform.environment['DB_NAME'] ?? 'mnemo_cards_dev',
|
||||
username: Platform.environment['DB_USER'] ?? 'mnemo_user',
|
||||
password: Platform.environment['DB_PASSWORD'] ?? '',
|
||||
useSsl: Platform.environment['DB_SSL_MODE'] == 'require',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
onCreate: (Migrator m) async {
|
||||
print('Creating database schema...');
|
||||
await m.createAll();
|
||||
print('Database schema created successfully');
|
||||
|
||||
// Создать индексы для оптимизации
|
||||
await _createIndexes();
|
||||
},
|
||||
onUpgrade: (Migrator m, int from, int to) async {
|
||||
print('Migrating database from version $from to $to');
|
||||
|
||||
// Миграции при обновлении схемы
|
||||
// if (from < 2) {
|
||||
// await m.addColumn(users, users.phoneNumber);
|
||||
// }
|
||||
},
|
||||
beforeOpen: (details) async {
|
||||
print('Opening database connection...');
|
||||
|
||||
// Проверка подключения
|
||||
final result = await customSelect('SELECT 1 as test').getSingle();
|
||||
print('Database connection successful: ${result.data}');
|
||||
|
||||
// Включить foreign key constraints
|
||||
await customStatement('SET CONSTRAINTS ALL IMMEDIATE');
|
||||
},
|
||||
);
|
||||
|
||||
/// Создание индексов для оптимизации запросов
|
||||
Future<void> _createIndexes() async {
|
||||
print('Creating indexes...');
|
||||
|
||||
// Users indexes
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_users_admin ON users(admin) WHERE admin = true');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_users_not_deleted ON users(is_deleted) WHERE is_deleted = false');
|
||||
|
||||
// UserDatas indexes
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_user_datas_user_id ON user_datas(user_id)');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_user_datas_last_online ON user_datas(last_time_online DESC)');
|
||||
|
||||
// Auth indexes
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_tokens_user_id ON tokens(user_id)');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_tokens_expires ON tokens(expires) WHERE expires > NOW()');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id)');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_refresh_tokens_jti ON refresh_tokens(jti)');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_refresh_tokens_not_blacklisted ON refresh_tokens(is_blacklisted) WHERE is_blacklisted = false');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_telegram_codes_code ON telegram_auth_codes(code)');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_telegram_codes_not_used ON telegram_auth_codes(is_used) WHERE is_used = false');
|
||||
|
||||
// CardPacks indexes
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_packs_enabled ON card_packs(enabled) WHERE enabled = true');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_packs_order ON card_packs("order")');
|
||||
|
||||
// GameCards indexes
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_cards_original ON game_cards(original)');
|
||||
|
||||
// UserPacks indexes
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_user_packs_user_id ON user_packs(user_id)');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_user_packs_pack_id ON user_packs(pack_id)');
|
||||
|
||||
// Payments indexes
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments(user_id)');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status)');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_payments_created ON payments(date DESC)');
|
||||
|
||||
// Subscriptions indexes
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON user_subscriptions(user_id)');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_subscriptions_active ON user_subscriptions(finish) WHERE finish > NOW()');
|
||||
|
||||
// Tests indexes
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_test_questions_test_id ON test_questions(test_id)');
|
||||
|
||||
// StudySessions indexes
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON study_sessions(user_id)');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_sessions_started ON study_sessions(start_time DESC)');
|
||||
|
||||
// PromoCodes indexes
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_promo_codes_code ON promo_codes(code)');
|
||||
await customStatement('CREATE INDEX IF NOT EXISTS idx_promo_codes_campaign_id ON promo_codes(campaign_id)');
|
||||
|
||||
print('Indexes created successfully');
|
||||
}
|
||||
}
|
||||
13643
mnemo_cards_backend/lib/database/database.g.dart
Normal file
13643
mnemo_cards_backend/lib/database/database.g.dart
Normal file
File diff suppressed because it is too large
Load diff
30
mnemo_cards_backend/lib/database/tables/achievements.dart
Normal file
30
mnemo_cards_backend/lib/database/tables/achievements.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../converters.dart';
|
||||
import 'users.dart';
|
||||
|
||||
/// Таблица UserAchievements - достижения пользователей
|
||||
class UserAchievements extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
IntColumn get userId => integer().references(Users, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
// Achievement ID (from AchievementDefinitions)
|
||||
TextColumn get achievementId => text()();
|
||||
|
||||
// When achievement was unlocked
|
||||
DateTimeColumn get unlockedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
// Progress (0.0 to 1.0)
|
||||
RealColumn get progress => real().withDefault(const Constant(1.0))();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
'UNIQUE(user_id, achievement_id)',
|
||||
];
|
||||
}
|
||||
|
||||
// Конвертеры импортированы из converters.dart
|
||||
49
mnemo_cards_backend/lib/database/tables/auth.dart
Normal file
49
mnemo_cards_backend/lib/database/tables/auth.dart
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import 'users.dart';
|
||||
|
||||
/// Таблица Tokens - токены авторизации пользователей
|
||||
class Tokens extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get userId => integer().references(Users, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
TextColumn get token => text().unique()();
|
||||
TextColumn get externalUserId => text()();
|
||||
|
||||
DateTimeColumn get created => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get expires => dateTime()();
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
'CONSTRAINT valid_expiry CHECK (expires > created)',
|
||||
];
|
||||
}
|
||||
|
||||
/// Таблица RefreshTokens - refresh токены для JWT
|
||||
class RefreshTokens extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get userId => integer().references(Users, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
TextColumn get jti => text().unique()(); // JWT ID
|
||||
|
||||
BoolColumn get isBlacklisted => boolean().withDefault(const Constant(false))();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get expiresAt => dateTime()();
|
||||
}
|
||||
|
||||
/// Таблица TelegramAuthCodes - коды для авторизации через Telegram
|
||||
class TelegramAuthCodes extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
TextColumn get code => text().unique()();
|
||||
TextColumn get telegramUserId => text()();
|
||||
TextColumn get telegramUsername => text().nullable()();
|
||||
TextColumn get firstName => text().nullable()();
|
||||
TextColumn get lastName => text().nullable()();
|
||||
|
||||
BoolColumn get isUsed => boolean().withDefault(const Constant(false))();
|
||||
DateTimeColumn get usedAt => dateTime().nullable()();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get expiresAt => dateTime()();
|
||||
}
|
||||
58
mnemo_cards_backend/lib/database/tables/discounts.dart
Normal file
58
mnemo_cards_backend/lib/database/tables/discounts.dart
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../converters.dart';
|
||||
import 'users.dart';
|
||||
|
||||
/// Таблица DiscountCampaigns - кампании скидок
|
||||
class DiscountCampaigns extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
TextColumn get name => text().nullable()();
|
||||
|
||||
DateTimeColumn get start => dateTime()();
|
||||
DateTimeColumn get finish => dateTime()();
|
||||
|
||||
// Статус (enum as string)
|
||||
TextColumn get status => text()(); // created, active, expired, disabled
|
||||
|
||||
// Теги (JSON array)
|
||||
TextColumn get tags => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const StringListConverter())();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
|
||||
}
|
||||
|
||||
/// Таблица Discounts - скидки
|
||||
class Discounts extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get campaignId => integer().references(DiscountCampaigns, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
// Процент скидки (0-100)
|
||||
RealColumn get discountPercent => real()();
|
||||
|
||||
// Продукты (JSON array)
|
||||
TextColumn get products => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const JsonListConverter())();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
|
||||
}
|
||||
|
||||
/// Junction table для связи Discounts ↔ UserDatas (many-to-many)
|
||||
class DiscountUserDatas extends Table {
|
||||
IntColumn get discountId => integer().references(Discounts, #id, onDelete: KeyAction.cascade)();
|
||||
IntColumn get userId => integer().references(Users, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
DateTimeColumn get grantedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {discountId, userId};
|
||||
}
|
||||
|
||||
// Конвертеры импортированы из converters.dart
|
||||
81
mnemo_cards_backend/lib/database/tables/packs.dart
Normal file
81
mnemo_cards_backend/lib/database/tables/packs.dart
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../converters.dart';
|
||||
|
||||
/// Таблица CardPacks - наборы карточек
|
||||
class CardPacks extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
// Основная информация
|
||||
TextColumn get title => text()();
|
||||
TextColumn get subtitle => text()();
|
||||
TextColumn get description => text().nullable()();
|
||||
|
||||
// Визуальное оформление
|
||||
TextColumn get color => text().nullable()();
|
||||
TextColumn get cover => text().nullable()(); // URL обложки
|
||||
|
||||
// Параметры
|
||||
IntColumn get size => integer()(); // количество карточек
|
||||
TextColumn get version => text().nullable()();
|
||||
IntColumn get order => integer().withDefault(const Constant(0))();
|
||||
BoolColumn get enabled => boolean().withDefault(const Constant(true))();
|
||||
|
||||
// Порядок карточек (JSON array of IDs)
|
||||
TextColumn get cardsOrder => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const IntListConverter())();
|
||||
|
||||
// Store IDs для покупок
|
||||
TextColumn get googlePlayId => text().nullable()();
|
||||
TextColumn get rustoreId => text().nullable()();
|
||||
TextColumn get appStoreId => text().nullable()();
|
||||
|
||||
// Цена
|
||||
TextColumn get price => text().nullable()();
|
||||
TextColumn get currency => text().withDefault(const Constant('RUB'))();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
|
||||
}
|
||||
|
||||
/// Таблица GameCards - карточки для изучения
|
||||
class GameCards extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get packId => integer().references(CardPacks, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
// Основной контент
|
||||
TextColumn get original => text()(); // слово на иностранном языке
|
||||
TextColumn get translation => text()(); // перевод
|
||||
TextColumn get mnemo => text().nullable()(); // мнемоническая подсказка
|
||||
|
||||
// Изображения
|
||||
TextColumn get image => text()(); // основное изображение
|
||||
TextColumn get imageBack => text().nullable()(); // изображение на обратной стороне
|
||||
|
||||
// Произношение
|
||||
TextColumn get transcription => text().nullable()();
|
||||
TextColumn get transcriptionMnemo => text().nullable()();
|
||||
|
||||
// Дополнительная информация
|
||||
TextColumn get back => text().nullable()(); // дополнительный текст
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
|
||||
}
|
||||
|
||||
/// Таблица VoiceModels - голосовые файлы для карточек
|
||||
class VoiceModels extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get cardId => integer().references(GameCards, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
TextColumn get voiceUrl => text()(); // URL аудиофайла
|
||||
TextColumn get language => text()(); // язык озвучки
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
// IntListConverter импортирован из converters.dart
|
||||
38
mnemo_cards_backend/lib/database/tables/payments.dart
Normal file
38
mnemo_cards_backend/lib/database/tables/payments.dart
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../converters.dart';
|
||||
import 'users.dart';
|
||||
|
||||
/// Таблица Payments - платежи
|
||||
class Payments extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get userId => integer().references(Users, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
TextColumn get amount => text()();
|
||||
TextColumn get currency => text()();
|
||||
|
||||
// Статус и платежная система (enum as string)
|
||||
TextColumn get status => text()(); // PaymentStatus
|
||||
TextColumn get paymentSystem => text()(); // PaymentSystem
|
||||
|
||||
TextColumn get externalToken => text().nullable()();
|
||||
TextColumn get meta => text().nullable()();
|
||||
|
||||
DateTimeColumn get date => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
// Продукты (JSON array)
|
||||
TextColumn get products => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const JsonListConverter())();
|
||||
|
||||
// Deprecated fields (для обратной совместимости)
|
||||
TextColumn get packs => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const StringListConverter())();
|
||||
BoolColumn get subscription => boolean().withDefault(const Constant(false))();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
// Конвертеры импортированы из converters.dart
|
||||
54
mnemo_cards_backend/lib/database/tables/promo_codes.dart
Normal file
54
mnemo_cards_backend/lib/database/tables/promo_codes.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../converters.dart';
|
||||
import 'users.dart';
|
||||
|
||||
/// Таблица PromoCodesCampaigns - кампании промокодов
|
||||
class PromoCodesCampaigns extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
TextColumn get template => text()();
|
||||
TextColumn get name => text().nullable()();
|
||||
|
||||
// Продукты (JSON array)
|
||||
TextColumn get products => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const JsonListConverter())();
|
||||
|
||||
IntColumn get activationsPerCode => integer()();
|
||||
IntColumn get activationsPerUser => integer()();
|
||||
IntColumn get generationSize => integer()();
|
||||
|
||||
DateTimeColumn get start => dateTime()();
|
||||
DateTimeColumn get finish => dateTime()();
|
||||
|
||||
// Статус (enum as string)
|
||||
TextColumn get status => text()(); // created, preparing, ready, active, disabled
|
||||
|
||||
// Теги (JSON array)
|
||||
TextColumn get tags => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const StringListConverter())();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
|
||||
}
|
||||
|
||||
/// Таблица PromoCodes - промокоды
|
||||
class PromoCodes extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get campaignId => integer().references(PromoCodesCampaigns, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
TextColumn get code => text().unique()();
|
||||
IntColumn get activations => integer().withDefault(const Constant(0))();
|
||||
|
||||
// Индивидуальный промокод (связан с пользователем)
|
||||
IntColumn get userId => integer().nullable().references(Users, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
// Конвертеры импортированы из converters.dart
|
||||
53
mnemo_cards_backend/lib/database/tables/relations.dart
Normal file
53
mnemo_cards_backend/lib/database/tables/relations.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import 'users.dart';
|
||||
import 'packs.dart' show CardPacks, GameCards, VoiceModels;
|
||||
|
||||
/// Junction table для связи Users ↔ CardPacks (многие ко многим)
|
||||
/// Хранит информацию о том, какие паки куплены пользователем
|
||||
class UserPacks extends Table {
|
||||
IntColumn get userId => integer().references(Users, #id, onDelete: KeyAction.cascade)();
|
||||
IntColumn get packId => integer().references(CardPacks, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
// Когда пользователь получил доступ к паку
|
||||
DateTimeColumn get grantedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
// Как пользователь получил пак (purchase, promo, free, admin)
|
||||
TextColumn get grantType => text().withDefault(const Constant('purchase'))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {userId, packId};
|
||||
}
|
||||
|
||||
/// Таблица PreviewCards - связь CardPacks с preview карточками
|
||||
/// Хранит какие карточки показывать в превью пака
|
||||
class PreviewCards extends Table {
|
||||
IntColumn get packId => integer().references(CardPacks, #id, onDelete: KeyAction.cascade)();
|
||||
IntColumn get cardId => integer().references(GameCards, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
IntColumn get order => integer().withDefault(const Constant(0))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {packId, cardId};
|
||||
}
|
||||
|
||||
/// Таблица CardPackCards - связь CardPacks с GameCards (many-to-many)
|
||||
/// Хранит какие карточки принадлежат какому паку
|
||||
class CardPackCards extends Table {
|
||||
IntColumn get packId => integer().references(CardPacks, #id, onDelete: KeyAction.cascade)();
|
||||
IntColumn get cardId => integer().references(GameCards, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
IntColumn get order => integer().withDefault(const Constant(0))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {packId, cardId};
|
||||
}
|
||||
|
||||
/// Таблица CardVoices - связь GameCards с VoiceModels (many-to-many)
|
||||
/// Хранит какие голосовые файлы принадлежат какой карточке
|
||||
class CardVoices extends Table {
|
||||
IntColumn get cardId => integer().references(GameCards, #id, onDelete: KeyAction.cascade)();
|
||||
IntColumn get voiceId => integer().references(VoiceModels, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {cardId, voiceId};
|
||||
}
|
||||
24
mnemo_cards_backend/lib/database/tables/statistics.dart
Normal file
24
mnemo_cards_backend/lib/database/tables/statistics.dart
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import 'users.dart';
|
||||
|
||||
/// Таблица StudySessions - сессии изучения
|
||||
class StudySessions extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get userId => integer().references(Users, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
TextColumn get sessionId => text().nullable().unique()();
|
||||
|
||||
DateTimeColumn get startTime => dateTime()();
|
||||
DateTimeColumn get endTime => dateTime().nullable()();
|
||||
|
||||
IntColumn get wordsLearned => integer().withDefault(const Constant(0))();
|
||||
IntColumn get testsCompleted => integer().withDefault(const Constant(0))();
|
||||
RealColumn get accuracy => real().withDefault(const Constant(0.0))();
|
||||
|
||||
TextColumn get packId => text().nullable()();
|
||||
TextColumn get testId => text().nullable()();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
51
mnemo_cards_backend/lib/database/tables/subscriptions.dart
Normal file
51
mnemo_cards_backend/lib/database/tables/subscriptions.dart
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import '../converters.dart';
|
||||
import 'users.dart';
|
||||
|
||||
/// Таблица SubscriptionPlans - планы подписки
|
||||
class SubscriptionPlans extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
// UI информация (JSON)
|
||||
TextColumn get ui => text().nullable().map(NullAwareTypeConverter.wrap(const JsonMapConverter()))();
|
||||
|
||||
// Цена и валюта
|
||||
TextColumn get price => text()();
|
||||
TextColumn get currency => text()();
|
||||
IntColumn get durationDays => integer()();
|
||||
|
||||
// Функции подписки (JSON array)
|
||||
TextColumn get features => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const JsonListConverter())();
|
||||
|
||||
// Платежная система
|
||||
TextColumn get paymentId => text().nullable()();
|
||||
TextColumn get paymentSystem => text()(); // enum as string
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
|
||||
}
|
||||
|
||||
/// Таблица UserSubscriptions - подписки пользователей
|
||||
class UserSubscriptions extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get userId => integer().unique().references(Users, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
DateTimeColumn get start => dateTime()();
|
||||
DateTimeColumn get finish => dateTime()();
|
||||
|
||||
// Функции подписки (JSON array)
|
||||
TextColumn get features => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const JsonListConverter())();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
// Конвертеры импортированы из converters.dart
|
||||
89
mnemo_cards_backend/lib/database/tables/tasks.dart
Normal file
89
mnemo_cards_backend/lib/database/tables/tasks.dart
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../converters.dart';
|
||||
import 'users.dart';
|
||||
|
||||
/// Таблица Tasks - задачи системы
|
||||
class Tasks extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
TextColumn get name => text()();
|
||||
IntColumn get minCycleMillis => integer()();
|
||||
IntColumn get maxCycleMillis => integer()();
|
||||
IntColumn get intervalMillis => integer()();
|
||||
IntColumn get timeoutMillis => integer()();
|
||||
|
||||
TextColumn get status => text().nullable()();
|
||||
TextColumn get description => text().nullable()();
|
||||
DateTimeColumn get lastExecution => dateTime().nullable()();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
/// Таблица UserTasks - задачи пользователей
|
||||
class UserTasks extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
TextColumn get title => text()();
|
||||
TextColumn get description => text()();
|
||||
TextColumn get type => text()(); // app_internal, external, social
|
||||
TextColumn get difficulty => text()(); // easy, medium, hard
|
||||
TextColumn get status => text()(); // available, in_progress, completed, expired, failed
|
||||
|
||||
// Награды (JSON array)
|
||||
TextColumn get rewards => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const JsonListConverter())();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get expiresAt => dateTime()();
|
||||
DateTimeColumn get completedAt => dateTime().nullable()();
|
||||
|
||||
TextColumn get proofUrl => text().nullable()();
|
||||
TextColumn get instructions => text().nullable()();
|
||||
|
||||
// Теги (JSON array)
|
||||
TextColumn get tags => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const StringListConverter())();
|
||||
|
||||
TextColumn get imageUrl => text().nullable()();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
/// Таблица UserTaskProgresses - прогресс выполнения задач пользователями
|
||||
class UserTaskProgresses extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get userId => integer().references(Users, #id, onDelete: KeyAction.cascade)();
|
||||
IntColumn get taskId => integer()(); // Reference to UserTasks, but not FK to avoid circular deps
|
||||
|
||||
// Прогресс (JSON)
|
||||
TextColumn get progress => text()
|
||||
.withDefault(const Constant('{}'))
|
||||
.map(const JsonMapConverter())();
|
||||
|
||||
DateTimeColumn get startedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
/// Таблица UserTaskResults - результаты выполнения задач
|
||||
class UserTaskResults extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get userId => integer().references(Users, #id, onDelete: KeyAction.cascade)();
|
||||
IntColumn get taskId => integer()(); // Reference to UserTasks
|
||||
|
||||
// Результаты (JSON)
|
||||
TextColumn get results => text()
|
||||
.withDefault(const Constant('{}'))
|
||||
.map(const JsonMapConverter())();
|
||||
|
||||
DateTimeColumn get completedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
// Конвертеры импортированы из converters.dart
|
||||
17
mnemo_cards_backend/lib/database/tables/telegram.dart
Normal file
17
mnemo_cards_backend/lib/database/tables/telegram.dart
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
/// Таблица ShareRequests - запросы на шаринг через Telegram
|
||||
class ShareRequests extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
TextColumn get telegramUserId => text()();
|
||||
TextColumn get telegramUsername => text().nullable()();
|
||||
|
||||
IntColumn get sharedCardId => integer().nullable()();
|
||||
|
||||
DateTimeColumn get requestedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
63
mnemo_cards_backend/lib/database/tables/tests.dart
Normal file
63
mnemo_cards_backend/lib/database/tables/tests.dart
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../converters.dart';
|
||||
import 'packs.dart';
|
||||
|
||||
/// Таблица Tests - тесты
|
||||
class Tests extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
TextColumn get name => text()();
|
||||
TextColumn get color => text().nullable()();
|
||||
TextColumn get cover => text().nullable()();
|
||||
TextColumn get version => text().nullable()();
|
||||
TextColumn get time => text().nullable()();
|
||||
TextColumn get timeSubtitle => text().nullable()();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
|
||||
}
|
||||
|
||||
/// Таблица TestQuestions - вопросы тестов
|
||||
class TestQuestions extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get testId => integer().references(Tests, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
// Тип вопроса (enum as string)
|
||||
TextColumn get questionType => text()();
|
||||
TextColumn get body => text()();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
/// Таблица TestPackRelations - связь Tests с CardPacks (many-to-many)
|
||||
class TestPackRelations extends Table {
|
||||
IntColumn get testId => integer().references(Tests, #id, onDelete: KeyAction.cascade)();
|
||||
IntColumn get packId => integer().references(CardPacks, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {testId, packId};
|
||||
}
|
||||
|
||||
/// Таблица TestStatistics - статистика прохождения тестов
|
||||
class TestStatistics extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get userId => integer()(); // Reference to Users, but not FK to avoid circular deps
|
||||
IntColumn get testId => integer().references(Tests, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
// Результаты (JSON)
|
||||
TextColumn get results => text()
|
||||
.withDefault(const Constant('{}'))
|
||||
.map(const JsonMapConverter())();
|
||||
|
||||
DateTimeColumn get completedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
// Конвертеры импортированы из converters.dart
|
||||
80
mnemo_cards_backend/lib/database/tables/users.dart
Normal file
80
mnemo_cards_backend/lib/database/tables/users.dart
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import 'package:drift/drift.dart';
|
||||
import '../converters.dart';
|
||||
|
||||
/// Таблица Users - основная информация о пользователях
|
||||
class Users extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get externalUserId => text().unique()();
|
||||
TextColumn get name => text().nullable()();
|
||||
TextColumn get email => text().nullable()();
|
||||
BoolColumn get admin => boolean().withDefault(const Constant(false))();
|
||||
|
||||
// UserSettings (JSON)
|
||||
TextColumn get userSettings => text().nullable()();
|
||||
|
||||
// Purchases (JSON array)
|
||||
TextColumn get purchases => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const StringListConverter())();
|
||||
|
||||
// Audit fields
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
|
||||
|
||||
// Primary key is auto-increment id, no need to override
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
'CONSTRAINT valid_email CHECK (email IS NULL OR email LIKE \'%@%\')',
|
||||
];
|
||||
}
|
||||
|
||||
/// Таблица UserDatas - расширенная информация о пользователе
|
||||
class UserDatas extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
IntColumn get userId => integer().unique().references(Users, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
// Статистика
|
||||
IntColumn get totalStudyTimeMinutes => integer().withDefault(const Constant(0))();
|
||||
IntColumn get currentStreak => integer().withDefault(const Constant(0))();
|
||||
IntColumn get longestStreak => integer().withDefault(const Constant(0))();
|
||||
IntColumn get totalCards => integer().withDefault(const Constant(0))();
|
||||
IntColumn get totalTests => integer().withDefault(const Constant(0))();
|
||||
|
||||
// Временные метки
|
||||
DateTimeColumn get lastTimeOnline => dateTime().nullable()();
|
||||
DateTimeColumn get registrationDate => dateTime().withDefault(currentDateAndTime)();
|
||||
TextColumn get lastTestSessionToken => text().nullable()();
|
||||
|
||||
// Сложные структуры (JSON)
|
||||
TextColumn get words => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const JsonListConverter())();
|
||||
|
||||
TextColumn get tags => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const StringListConverter())();
|
||||
|
||||
TextColumn get packProgress => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const JsonListConverter())();
|
||||
|
||||
TextColumn get studyDates => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const DateTimeListConverter())();
|
||||
|
||||
TextColumn get achievements => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const JsonListConverter())();
|
||||
|
||||
TextColumn get categoryMinutes => text()
|
||||
.withDefault(const Constant('{}'))
|
||||
.map(const JsonMapConverter())();
|
||||
|
||||
// Audit
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
// Конвертеры импортированы из converters.dart
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/api/mnemo_shelf.dart';
|
||||
import 'package:mnemo_cards_backend/cron/add_free_packs.dart';
|
||||
import 'package:mnemo_cards_backend/cron/backup.dart';
|
||||
import 'package:mnemo_cards_backend/cron/cron_executor.dart';
|
||||
import 'package:mnemo_cards_backend/cron/generate_promocodes.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
|
||||
import 'package:mnemo_cards_backend/user/user_manager.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_backend/tests/test_manager.dart';
|
||||
|
||||
import 'api/di/injector.dart';
|
||||
|
|
@ -22,44 +21,56 @@ import 'cron/test_generator.dart';
|
|||
import 'cron/update_online_users.dart';
|
||||
import 'packs/free_packs_distributor.dart';
|
||||
|
||||
late Isar isar;
|
||||
late AppDatabase database;
|
||||
|
||||
late final String WORK_DIR;
|
||||
|
||||
|
||||
Future<Isar> _initIsar(
|
||||
String dir,
|
||||
bool debugMode,
|
||||
) async {
|
||||
/// Инициализация подключения к PostgreSQL
|
||||
Future<AppDatabase> _initDatabase() async {
|
||||
var attempt = 0;
|
||||
while (attempt < 5) {
|
||||
print('Attempt $attempt to initialize Isar');
|
||||
try {
|
||||
return isar = await IsarConnector().connect(
|
||||
dir: dir,
|
||||
name: 'db',
|
||||
inspector: debugMode,
|
||||
);
|
||||
} catch (e, s) {
|
||||
print('Error initializing Isar: $e');
|
||||
log('Error initializing Isar: $e', error: e, stackTrace: s);
|
||||
const maxAttempts = 5;
|
||||
|
||||
while (attempt < maxAttempts) {
|
||||
print('Attempt ${attempt + 1}/$maxAttempts to connect to PostgreSQL...');
|
||||
|
||||
try {
|
||||
// Создать подключение из environment variables
|
||||
final db = AppDatabase.fromEnvironment();
|
||||
|
||||
// Проверить подключение
|
||||
await db.customSelect('SELECT 1').getSingle();
|
||||
|
||||
print('✅ Successfully connected to PostgreSQL');
|
||||
return database = db;
|
||||
|
||||
} catch (e, s) {
|
||||
print('❌ Error connecting to PostgreSQL: $e');
|
||||
log('Error connecting to PostgreSQL: $e', error: e, stackTrace: s);
|
||||
|
||||
attempt++;
|
||||
if (attempt < maxAttempts) {
|
||||
final delay = Duration(seconds: attempt * 5);
|
||||
print('Retrying in ${delay.inSeconds} seconds...');
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Future.delayed(Duration(seconds: attempt * 10));
|
||||
attempt++;
|
||||
}
|
||||
throw Exception('Failed to initialize Isar');
|
||||
|
||||
throw Exception('Failed to connect to PostgreSQL after $maxAttempts attempts');
|
||||
}
|
||||
|
||||
void main() async {
|
||||
// WidgetsFlutterBinding.ensureInitialized();
|
||||
// final dir = (await getApplicationDocumentsDirectory()).path;
|
||||
|
||||
// Чтение переменных окружения вместо CLI аргументов
|
||||
final dir = Platform.environment['ISAR_DIR'] ?? 'isar';
|
||||
final backupDir = Platform.environment['BACKUP_DIR'] ??
|
||||
'../mnemo_cards_telegram_bot/backups/';
|
||||
print('🚀 Starting Mnemo Cards Backend...');
|
||||
|
||||
// Читаем environment variables
|
||||
final backupDir = Platform.environment['BACKUP_DIR'] ?? '../backups/';
|
||||
WORK_DIR = Platform.environment['WORK_DIR'] ?? '/root/mnemo_cards_backend';
|
||||
|
||||
final debugMode = Platform.environment['DEBUG'] == 'true' ||
|
||||
Platform.environment['DEBUG'] == '1';
|
||||
|
||||
print('📂 Working directory: $WORK_DIR');
|
||||
print('🐛 Debug mode: $debugMode');
|
||||
|
||||
print(
|
||||
'running server in ${(await Process.run('pwd', [], runInShell: true)).stdout}',
|
||||
);
|
||||
|
|
@ -75,45 +86,61 @@ void main() async {
|
|||
);
|
||||
print(pythonInit.stdout);
|
||||
print(pythonInit.stderr);
|
||||
// if (pythonInit.stderr != null) {
|
||||
// throw Exception('Not inited');
|
||||
// }
|
||||
} else {
|
||||
log('\n\n*******\nRUSTORE IS DISABLED\n******\n\n', level: 1000);
|
||||
}
|
||||
|
||||
// await Isar.initializeIsarCore(download: true);
|
||||
final debugMode = Platform.environment['DEBUG'] == 'true' ||
|
||||
Platform.environment['DEBUG'] == '1';
|
||||
|
||||
_initIsar(dir, debugMode).then((isar) async {
|
||||
getIt.registerSingleton<Isar>(isar);
|
||||
configureDependencies();
|
||||
await getIt<MnemoShelf>().initV2();
|
||||
|
||||
// ignore: unawaited_futures
|
||||
CronManager([
|
||||
DeleteOldArchives(),
|
||||
CheckAdminsTask(),
|
||||
TestGeneratorTask(getIt.get<TestManager>()),
|
||||
getIt.get<CheckPaymentTask>(),
|
||||
AddFreePacks(getIt.get<FreePacksDistributor>()),
|
||||
Backup(backupDir),
|
||||
GeneratePromocodes(),
|
||||
DiscountCampaignTask(getIt.get<DiscountsManager>()),
|
||||
UpdateOnlineUsersTask(getIt.get<UserManager>()),
|
||||
TasksSeederTask(),
|
||||
]).init();
|
||||
|
||||
});
|
||||
|
||||
// Инициализация PostgreSQL
|
||||
try {
|
||||
await _initDatabase();
|
||||
|
||||
// Регистрация в DI
|
||||
getIt.registerSingleton<AppDatabase>(database);
|
||||
|
||||
// Настройка остальных зависимостей
|
||||
configureDependencies();
|
||||
|
||||
// Запуск API сервера
|
||||
print('🌐 Starting API server...');
|
||||
await getIt<MnemoShelf>().initV2();
|
||||
|
||||
// Запуск cron jobs
|
||||
print('⏰ Starting cron jobs...');
|
||||
// ignore: unawaited_futures
|
||||
CronManager([
|
||||
DeleteOldArchives(),
|
||||
CheckAdminsTask(),
|
||||
// TestGeneratorTask(getIt.get<TestManager>()), // TODO: enable after TestManager migration
|
||||
getIt.get<CheckPaymentTask>(),
|
||||
AddFreePacks(getIt.get<FreePacksDistributor>()),
|
||||
Backup(backupDir),
|
||||
GeneratePromocodes(),
|
||||
DiscountCampaignTask(getIt.get<DiscountsManager>()),
|
||||
UpdateOnlineUsersTask(getIt.get<UserManager>()),
|
||||
TasksSeederTask(),
|
||||
]).init();
|
||||
|
||||
print('✅ Backend started successfully!');
|
||||
|
||||
} catch (e, s) {
|
||||
print('❌ Fatal error starting backend: $e');
|
||||
log('Fatal error starting backend: $e', error: e, stackTrace: s);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Обработка SIGTERM для graceful shutdown
|
||||
ProcessSignal.sigterm.watch().listen((signal) async {
|
||||
print('🛑 Received SIGTERM, shutting down gracefully...');
|
||||
|
||||
try {
|
||||
await isar.close();
|
||||
// Закрыть подключение к БД
|
||||
await database.close();
|
||||
print('✅ Database connection closed');
|
||||
} catch (e, s) {
|
||||
log('Error closing Isar: $e', error: e, stackTrace: s);
|
||||
print('Error closing Isar: $e');
|
||||
print('❌ Error closing database: $e');
|
||||
log('Error closing database: $e', error: e, stackTrace: s);
|
||||
}
|
||||
|
||||
exit(0);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
113
mnemo_cards_backend/lib/packs/card_pack_drift_extension.dart
Normal file
113
mnemo_cards_backend/lib/packs/card_pack_drift_extension.dart
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
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';
|
||||
|
||||
/// 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
|
||||
|
||||
return CardPackPreviewDto(
|
||||
id: id.toString(),
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
description: description,
|
||||
color: color,
|
||||
cover: cover,
|
||||
size: size,
|
||||
price: price,
|
||||
currency: currency,
|
||||
isAvailable: hasAccess,
|
||||
order: order,
|
||||
);
|
||||
}
|
||||
|
||||
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()))
|
||||
);
|
||||
|
||||
return CardPackDto(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
original: original,
|
||||
translation: translation,
|
||||
mnemo: mnemo,
|
||||
image: image,
|
||||
imageBack: imageBack,
|
||||
transcription: transcription,
|
||||
transcriptionMnemo: transcriptionMnemo,
|
||||
back: '', // TODO: add back field
|
||||
voices: voicesDto,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension для создания CardPack из DTO
|
||||
extension CardPackFromDto on CardPackDto {
|
||||
CardPacksCompanion toCompanion() {
|
||||
return CardPacksCompanion.insert(
|
||||
title: title,
|
||||
subtitle: subtitle ?? '',
|
||||
description: description,
|
||||
color: drift.Value(color),
|
||||
cover: drift.Value(cover),
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension для создания GameCard из DTO
|
||||
extension GameCardFromDto on GameCardDto {
|
||||
GameCardsCompanion toCompanion(int packId) {
|
||||
return GameCardsCompanion.insert(
|
||||
packId: packId,
|
||||
original: original,
|
||||
translation: translation,
|
||||
mnemo: drift.Value(mnemo),
|
||||
image: drift.Value(image),
|
||||
imageBack: drift.Value(imageBack),
|
||||
transcription: drift.Value(transcription),
|
||||
transcriptionMnemo: drift.Value(transcriptionMnemo),
|
||||
back: drift.Value(''), // TODO: back field
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,459 +1,135 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:image/image.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:mnemo_cards_backend/packs/card_dto_extension.dart';
|
||||
import 'package:mnemo_cards_backend/packs/card_pack_model_extension.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart' hide VoiceModel;
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
||||
import '../main.dart';
|
||||
import 'pack_dto_converter.dart';
|
||||
import 'pack_manager_extensions.dart';
|
||||
import 'card_pack_drift_extension.dart';
|
||||
|
||||
@lazySingleton
|
||||
class PackManager {
|
||||
final AppDatabase _db;
|
||||
final PackDtoConverter packDtoConverter;
|
||||
|
||||
PackManager(this.packDtoConverter);
|
||||
PackManager(this._db, this.packDtoConverter);
|
||||
|
||||
Future<List<CardPackPreviewDto>> listPacksPreviews(
|
||||
UserModel? userModel,
|
||||
Map<String, String>? params,
|
||||
) async {
|
||||
final models = await isar.cardPackModels
|
||||
.filter()
|
||||
.optional(
|
||||
userModel?.admin != true,
|
||||
(q) => q.enabledEqualTo(true),
|
||||
)
|
||||
.findAll();
|
||||
models.sort((p, n) => p.order.compareTo(n.order));
|
||||
return (await Future.wait(models.map(
|
||||
(model) async {
|
||||
final dto =
|
||||
await packDtoConverter.toCardPackPreviewDto(model, userModel);
|
||||
if (!model.enabled) {
|
||||
// Get packs from Drift database
|
||||
final packs = await _db.packDao.getAllPacks(
|
||||
enabledOnly: true,
|
||||
orderByField: 'order',
|
||||
);
|
||||
|
||||
return (await Future.wait(packs.map(
|
||||
(pack) async {
|
||||
final dto = await pack.toPreviewDto(userModel);
|
||||
if (!pack.enabled) {
|
||||
return dto.copyWith(subtitle: 'DISABLED ${dto.subtitle}');
|
||||
}
|
||||
return dto;
|
||||
},
|
||||
)))
|
||||
..sort((prev, next) {
|
||||
final bothAvailable = prev.isAvailable && next.isAvailable;
|
||||
if (bothAvailable) {
|
||||
return 0;
|
||||
}
|
||||
final prevAd = canOpenForAd(int.tryParse(prev.id));
|
||||
final nextAd = canOpenForAd(int.tryParse(next.id));
|
||||
final orderAds = prevAd == nextAd
|
||||
? 0
|
||||
: prevAd
|
||||
? -1
|
||||
: 1;
|
||||
final orderAvailability = prev.isAvailable == next.isAvailable
|
||||
? 0
|
||||
: prev.isAvailable
|
||||
? -1
|
||||
: 1;
|
||||
return orderAvailability == 0 ? orderAds : orderAvailability;
|
||||
});
|
||||
.toList();
|
||||
}
|
||||
|
||||
//no images
|
||||
Future<List<CardPackAction>> listPacksActions(
|
||||
Map<String, String?>? packsData,
|
||||
UserModel user,
|
||||
) async {
|
||||
final availablePacks = await isar.txn(() async {
|
||||
await user.subscriptionModel.load();
|
||||
if (user.subscriptionModel.value?.features
|
||||
.contains(SubscriptionFeatureEnum.packs) ==
|
||||
true) {
|
||||
return isar.cardPackModels.filter().enabledEqualTo(true).findAll();
|
||||
} else {
|
||||
return user.packs.filter().enabledEqualTo(true).findAll();
|
||||
}
|
||||
});
|
||||
List<CardPackAction> response = [];
|
||||
for (final pack in availablePacks) {
|
||||
final expectedUserVersion = pack.version;
|
||||
final userPackVersion = packsData?[pack.id.toString()];
|
||||
if (userPackVersion == expectedUserVersion) {
|
||||
//last version
|
||||
continue;
|
||||
} else {
|
||||
// needs update
|
||||
response.add(
|
||||
CardPackAction(
|
||||
id: pack.id.toString(),
|
||||
action: PackAction.update,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//if front has packs we dont have - action delete
|
||||
for (final id in packsData!.keys.where(
|
||||
(id) =>
|
||||
id.isNotEmpty &&
|
||||
availablePacks.where((p) => p.id.toString() == id.toString()).isEmpty,
|
||||
)) {
|
||||
response.add(
|
||||
CardPackAction(
|
||||
id: id,
|
||||
action: PackAction.delete,
|
||||
),
|
||||
);
|
||||
}
|
||||
return response;
|
||||
Future<CardPack?> getPack(int id) async {
|
||||
return await _db.packDao.getPackById(id);
|
||||
}
|
||||
|
||||
// Future<void> addPack(CardPackModel model, String productId) async {
|
||||
// await isar.writeTxn(
|
||||
// () async {
|
||||
// if (model.id != null && model.id! < 0) {
|
||||
// model = model.copyWith(id: null);
|
||||
// }
|
||||
// await isar.cardPackModels.put(model);
|
||||
// final cards = cardPackDto.cards.map((e) => e.toModel()).toList();
|
||||
// isar.gameCardModels.putAll(cards);
|
||||
// await model.cards
|
||||
// ..addAll(cards)
|
||||
// ..save();
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
|
||||
Future<bool> deletePack(String idString) async {
|
||||
return isar.writeTxn(() async {
|
||||
final id = int.tryParse(idString);
|
||||
if (id == null) return false;
|
||||
final model = await isar.cardPackModels.get(id);
|
||||
if (model == null) return false;
|
||||
return await isar.cardPackModels.delete(id);
|
||||
});
|
||||
Future<List<GameCard>> getCards(int packId) async {
|
||||
return await _db.packDao.getPackCards(packId);
|
||||
}
|
||||
|
||||
Future<bool> deleteCard(String idString) async {
|
||||
return isar.writeTxn(() async {
|
||||
final id = int.tryParse(idString);
|
||||
if (id == null) return false;
|
||||
final model = await isar.gameCardModels.get(id);
|
||||
if (model == null) return false;
|
||||
await model.packs.load();
|
||||
if (model.packs.isNotEmpty) return false;
|
||||
return await isar.gameCardModels.delete(id);
|
||||
});
|
||||
Future<GameCard?> getCard(int id) async {
|
||||
return await _db.packDao.getCardById(id);
|
||||
}
|
||||
|
||||
Future<void> addCard(GameCardDto dto) async {
|
||||
await isar.writeTxn(() async {
|
||||
GameCardModel model;
|
||||
if (dto.id < 0) {
|
||||
print('Creating new card ${dto.original}');
|
||||
model = dto.toModel().copyWith(id: null);
|
||||
} else {
|
||||
final existingModel = await isar.gameCardModels.get(dto.id);
|
||||
if (existingModel != null) {
|
||||
print('Editing card ${dto.id} ${dto.original}');
|
||||
model = existingModel.copyWith(
|
||||
image: dto.image ?? existingModel.image,
|
||||
mnemo: dto.mnemo ?? existingModel.mnemo,
|
||||
original: dto.original ?? existingModel.original,
|
||||
translation: dto.translation ?? existingModel.translation,
|
||||
transcription: dto.transcription ?? existingModel.transcription,
|
||||
imageBack: dto.imageBack ?? existingModel.imageBack,
|
||||
transcriptionMnemo:
|
||||
dto.transcriptionMnemo ?? existingModel.transcriptionMnemo,
|
||||
);
|
||||
} else {
|
||||
print('Creating new card with id ${dto.id} ${dto.original}');
|
||||
model = dto.toModel();
|
||||
}
|
||||
}
|
||||
final cardId = await isar.gameCardModels.put(model);
|
||||
if (model.image.length > 100) {
|
||||
print('Adding base64 image for card ${dto.id} ${dto.original}');
|
||||
//base64 image
|
||||
try {
|
||||
final data = base64Decode(model.image);
|
||||
final image = decodeImage(data)!;
|
||||
final fileName = '${cardId}_${dto.original}.png';
|
||||
final resizedImage = copyResize(image, width: 1024, height: 1024);
|
||||
final png = encodePng(resizedImage, level: 9);
|
||||
final imageFile = File('${assetsDirectory.path}/cards/$fileName');
|
||||
for (final imageSize in _ImageSize.values) {
|
||||
final resizedFile = _getResizedFile(fileName, 'cards', imageSize);
|
||||
try {
|
||||
if (await resizedFile.exists()) {
|
||||
print(
|
||||
'Removing old image cache for ${imageSize.name}: ${resizedFile.path}');
|
||||
await resizedFile.delete();
|
||||
}
|
||||
} catch (_) {
|
||||
print('Error deleting resized image ${resizedFile.path}');
|
||||
}
|
||||
}
|
||||
imageFile.writeAsBytesSync(png, flush: true);
|
||||
print('Card image saved ${imageFile.path}');
|
||||
model = model.copyWith(image: fileName);
|
||||
} catch (_) {
|
||||
throw Exception('Some exception');
|
||||
}
|
||||
}
|
||||
final id = await isar.gameCardModels.put(model);
|
||||
|
||||
final cardPackModel = (await isar.gameCardModels.get(id))!;
|
||||
await cardPackModel.packs.load();
|
||||
final packs = cardPackModel.packs.toList();
|
||||
for (final pack in packs) {
|
||||
await isar.cardPackModels.put(
|
||||
pack.copyWith(version: pack.version.incVersion),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<GetCardPackResponse?> getPackOrBuy(
|
||||
String packId,
|
||||
UserModel? userModel,
|
||||
) async {
|
||||
final model = await _fetchPackModel(packId);
|
||||
if (userModel?.packs.contains(packId) != true) {
|
||||
return packDtoConverter.toCardPackBuyDto(model, userModel);
|
||||
}
|
||||
return packDtoConverter.toDto(model);
|
||||
}
|
||||
|
||||
Future<CardPackModel?> getPackModelIfAvailable(
|
||||
UserModel userModel,
|
||||
String packId,
|
||||
) async {
|
||||
final id = int.parse(packId);
|
||||
userModel.subscriptionModel.load();
|
||||
final allPacksAvailable =
|
||||
userModel.subscriptionModel.value?.features.contains(
|
||||
SubscriptionFeatureEnum.packs,
|
||||
) ==
|
||||
true;
|
||||
if (allPacksAvailable) {
|
||||
final model = await isar.txn(() => isar.cardPackModels.get(id));
|
||||
if (model?.enabled == true) {
|
||||
return model;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
final model = await userModel.packs
|
||||
.filter()
|
||||
.enabledEqualTo(true)
|
||||
.idEqualTo(id)
|
||||
.findFirst();
|
||||
return model;
|
||||
}
|
||||
|
||||
Future<GetCardPackResponse?> getPack(
|
||||
String packId,
|
||||
UserModel? userModel,
|
||||
) async {
|
||||
// Preview pack (id=10) доступен без авторизации
|
||||
if (packId == '10') {
|
||||
final model = await _fetchPackModel(packId);
|
||||
if (model.enabled) {
|
||||
return packDtoConverter.toDto(model);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (userModel == null) {
|
||||
return null;
|
||||
}
|
||||
final model = await getPackModelIfAvailable(userModel, packId);
|
||||
if (model == null) {
|
||||
return null;
|
||||
}
|
||||
return packDtoConverter.toDto(model);
|
||||
}
|
||||
|
||||
Future<EditCardPackDto?> getEditPack(String packId) async {
|
||||
final id = int.tryParse(packId);
|
||||
if (id == null) return null;
|
||||
return isar.txn(() async {
|
||||
final model = await isar.cardPackModels.get(id);
|
||||
if (model == null) return null;
|
||||
return await packDtoConverter.toEditDto(model);
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> editPack(EditCardPackDto dto) async {
|
||||
return isar.writeTxn(() async {
|
||||
final oldId = int.tryParse(dto.id ?? '');
|
||||
final oldModel =
|
||||
oldId == null ? null : await isar.cardPackModels.get(oldId);
|
||||
final editModel = packDtoConverter.toModel(dto, oldModel);
|
||||
final dtoPreviewCardsIds =
|
||||
dto.previewCards?.map(int.tryParse).whereNotNull().toList() ?? [];
|
||||
final dtoAddCardsIds =
|
||||
dto.addCardIds?.map(int.tryParse).whereNotNull().toList() ?? [];
|
||||
final dtoAddTestIds =
|
||||
dto.addTestIds?.map(int.tryParse).whereNotNull().toList() ?? [];
|
||||
|
||||
final dtoPreviewCards = dtoPreviewCardsIds.isEmpty
|
||||
? <GameCardModel>[]
|
||||
: (await isar.gameCardModels.getAll(dtoPreviewCardsIds))
|
||||
.whereNotNull()
|
||||
.toList();
|
||||
final dtoAddCards = dtoAddCardsIds.isEmpty
|
||||
? <GameCardModel>[]
|
||||
: (await isar.gameCardModels.getAll(dtoAddCardsIds))
|
||||
.whereNotNull()
|
||||
.toList();
|
||||
final dtoAddTests = dtoAddTestIds.isEmpty
|
||||
? <TestModel>[]
|
||||
: (await isar.testModels.getAll(dtoAddTestIds))
|
||||
.whereNotNull()
|
||||
.toList();
|
||||
|
||||
final modelId = await isar.cardPackModels.put(
|
||||
editModel.copyWith.version(editModel.version.incVersion),
|
||||
);
|
||||
final savedEditModel = (await isar.cardPackModels.get(modelId))!;
|
||||
|
||||
if (dtoPreviewCards.isNotEmpty) {
|
||||
await savedEditModel.previewCards.reset();
|
||||
savedEditModel.previewCards.addAll(dtoPreviewCards);
|
||||
await savedEditModel.previewCards.save();
|
||||
}
|
||||
await savedEditModel.cards.load();
|
||||
savedEditModel.cards
|
||||
..removeWhere(
|
||||
(card) => dto.removeCardIds?.contains(card.id.toString()) ?? false,
|
||||
)
|
||||
..addAll(dtoAddCards);
|
||||
await savedEditModel.cards.save();
|
||||
|
||||
await savedEditModel.tests.load();
|
||||
savedEditModel.tests
|
||||
..removeWhere(
|
||||
(test) => dto.removeTestIds?.contains(test.id.toString()) ?? false,
|
||||
)
|
||||
..addAll(dtoAddTests);
|
||||
await savedEditModel.tests.save();
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<CardPackBuyDto?> getBuyPage(String packId, UserModel? user) async {
|
||||
final id = int.tryParse(packId);
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
if (await user?.packs.filter().idEqualTo(id).findFirst() == null) {
|
||||
final model = await _fetchPackModel(packId);
|
||||
return packDtoConverter.toCardPackBuyDto(model, user);
|
||||
}
|
||||
Future<VoiceModel?> getVoice(int id) async {
|
||||
// TODO: Implement in PackDao
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<CardPackBuyDto?> getPublicBuyPage(String packId) async {
|
||||
final id = int.tryParse(packId);
|
||||
if (id == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final model = await isar.cardPackModels.get(id);
|
||||
if (model == null || !model.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return packDtoConverter.toCardPackBuyDto(model, null);
|
||||
}
|
||||
|
||||
Future<CardPackModel> _fetchPackModel(String packId) async {
|
||||
return (await isar.cardPackModels.get(int.parse(packId)))!;
|
||||
}
|
||||
|
||||
Future<List<int>> fetchPackImagesArchive(
|
||||
String packId,
|
||||
int userId,
|
||||
String appVersion,
|
||||
) async {
|
||||
Future<List<VoiceModel>> getVoices(int cardId) async {
|
||||
// TODO: Implement in PackDao
|
||||
return [];
|
||||
// final pack = await _fetchPackModel(packId);
|
||||
// final archiveFile = File(
|
||||
// '${assetsDirectory.path}/pack_archives/${appVersion}/${packId}_${pack.version}.zip',
|
||||
// );
|
||||
|
||||
// try {
|
||||
// if (await archiveFile.exists()) {
|
||||
// return await archiveFile.readAsBytes();
|
||||
// }
|
||||
// } catch (e, s) {
|
||||
// log('Error when fetching pack images archive', error: e, stackTrace: s);
|
||||
// return [];
|
||||
// }
|
||||
// final cards = pack.cards;
|
||||
// final rawData = _fetchPackImages(cards.toList());
|
||||
// final archive = Archive();
|
||||
// for (final entry in rawData.entries) {
|
||||
// archive.addFile(ArchiveFile(entry.key, 0, entry.value));
|
||||
// }
|
||||
// final password = TokenGenerator.generateArchivePassword(
|
||||
// appVersion: appVersion,
|
||||
// id: packId,
|
||||
// );
|
||||
|
||||
// final bytes = ZipEncoder(
|
||||
// password: password,
|
||||
// ).encode(
|
||||
// archive,
|
||||
// level: Deflate.BEST_COMPRESSION,
|
||||
// )!;
|
||||
|
||||
// Future<void> saveFile() async {
|
||||
// try {
|
||||
// await archiveFile.create(recursive: true);
|
||||
// await archiveFile.writeAsBytes(bytes);
|
||||
// } catch (e, s) {
|
||||
// log(
|
||||
// 'Error when creating archive ${archiveFile.path}',
|
||||
// error: e,
|
||||
// stackTrace: s,
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
// unawaited(saveFile());
|
||||
|
||||
// return bytes;
|
||||
}
|
||||
|
||||
Map<String, List<int>> _fetchPackImages(List<GameCardModel> cards) {
|
||||
return Map.fromEntries(
|
||||
cards.map((e) {
|
||||
final empty = MapEntry(e.id.toString(), <int>[]);
|
||||
Future<CardPackDto> getPackDto(int id, UserModel? userModel) async {
|
||||
final pack = await getPack(id);
|
||||
if (pack == null) {
|
||||
throw StateError('Pack not found');
|
||||
}
|
||||
|
||||
final cards = await getCards(id);
|
||||
final voices = <VoiceModel>[];
|
||||
|
||||
for (final card in cards) {
|
||||
voices.addAll(await getVoices(card.id));
|
||||
}
|
||||
|
||||
return await pack.toDto(cards, voices, userModel);
|
||||
}
|
||||
|
||||
Future<List<String>> getPackPreviewImages(int packId) async {
|
||||
final pack = await getPack(packId);
|
||||
if (pack == null) {
|
||||
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 images = <String>[];
|
||||
for (final card in previewCards) {
|
||||
if (card.image.isNotEmpty) {
|
||||
try {
|
||||
final image = e.image.isEmpty
|
||||
? empty
|
||||
: MapEntry(
|
||||
e.id.toString(),
|
||||
File('${assetsDirectory.path}/cards/${e.image}')
|
||||
.readAsBytesSync(),
|
||||
);
|
||||
return image;
|
||||
final image = await card.image.base64Image;
|
||||
images.add(image);
|
||||
} catch (e, s) {
|
||||
log('error while reading image', error: e, stackTrace: s);
|
||||
return empty;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have enough preview images, fill with empty strings
|
||||
while (images.length < 6) {
|
||||
images.add('');
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
Future<Map<String, Uint8List>> getPackImages(int packId) async {
|
||||
final cards = await getCards(packId);
|
||||
final result = <String, Uint8List>{};
|
||||
|
||||
for (final card in cards) {
|
||||
if (card.image.isNotEmpty) {
|
||||
try {
|
||||
result[card.id.toString()] =
|
||||
File('${PackManagerUtils.assetsDirectory.path}/cards/${card.image}')
|
||||
.readAsBytesSync();
|
||||
} catch (e, s) {
|
||||
log('error while reading image', error: e, stackTrace: s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Static utility functions for PackManager
|
||||
class PackManagerUtils {
|
||||
static Directory get assetsDirectory {
|
||||
String mainPath = Platform.resolvedExecutable;
|
||||
if ((Platform.isMacOS || Platform.isLinux) &&
|
||||
|
|
@ -481,102 +157,4 @@ class PackManager {
|
|||
return Directory('');
|
||||
}
|
||||
}
|
||||
|
||||
static File _getResizedFile(String id, String type, _ImageSize size) =>
|
||||
File('${PackManager.assetsDirectory.path}/$type/${size.name}/$id');
|
||||
}
|
||||
|
||||
enum _ImageSize {
|
||||
big,
|
||||
medium,
|
||||
small,
|
||||
extraSmall,
|
||||
}
|
||||
|
||||
extension on _ImageSize {
|
||||
int get width => switch (this) {
|
||||
_ImageSize.big => 1024,
|
||||
_ImageSize.medium => 512,
|
||||
_ImageSize.small => 320,
|
||||
_ImageSize.extraSmall => 192,
|
||||
};
|
||||
}
|
||||
|
||||
extension coverStringExt on String {
|
||||
Future<String> get base64Image => _base64Image();
|
||||
|
||||
Future<String> _base64FromBytes(Uint8List bytes) async =>
|
||||
base64.normalize(base64Encode(bytes));
|
||||
|
||||
// base64 or cardId or 'cards/id' or 'images/id'
|
||||
Future<String> _base64Image([_ImageSize? size]) async {
|
||||
try {
|
||||
if (length > 100) {
|
||||
if (size != null) {
|
||||
final bytes = base64Decode(this);
|
||||
final image = decodeImage(bytes)!;
|
||||
final reizedImage = copyResize(image, width: size.width);
|
||||
return _base64FromBytes(encodePng(reizedImage));
|
||||
}
|
||||
//is base 64
|
||||
return this;
|
||||
}
|
||||
if (!contains('/')) {
|
||||
return _base64FromBytes(_getById(this, 'cards', size));
|
||||
} else if (startsWith('cards/')) {
|
||||
return _base64FromBytes(_getById(split('/').last, 'cards', size));
|
||||
} else if (startsWith('images/')) {
|
||||
return _base64FromBytes(_getById(split('/').last, 'images', size));
|
||||
}
|
||||
} catch (e, s) {
|
||||
log(e.toString(), stackTrace: s);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
Uint8List _getById(String id, String type, _ImageSize? size) {
|
||||
if (size == null) {
|
||||
return File('${PackManager.assetsDirectory.path}/$type/$id')
|
||||
.readAsBytesSync();
|
||||
}
|
||||
final resizedFile = PackManager._getResizedFile(id, type, size);
|
||||
if (resizedFile.existsSync()) {
|
||||
try {
|
||||
return resizedFile.readAsBytesSync();
|
||||
} catch (_) {
|
||||
print(
|
||||
'Error, deleting ${size.name} image with id:$id,type:$type (${resizedFile.path})');
|
||||
resizedFile.deleteSync();
|
||||
}
|
||||
} else {
|
||||
print(
|
||||
'No ${size.name} image with id:$id,type:$type (${resizedFile.path})');
|
||||
}
|
||||
final file = File('${PackManager.assetsDirectory.path}/$type/$id');
|
||||
if (file.existsSync()) {
|
||||
try {
|
||||
final bytes = file.readAsBytesSync();
|
||||
final image = decodeImage(bytes)!;
|
||||
final reizedImage = copyResize(image, width: size.width);
|
||||
final resizedBytes = encodePng(reizedImage);
|
||||
resizedFile.createSync(recursive: true);
|
||||
resizedFile.writeAsBytesSync(resizedBytes);
|
||||
print(
|
||||
'Saved ${size.name} image with id:$id,type:$type (${resizedFile.path})');
|
||||
return resizedBytes;
|
||||
} catch (e, s) {
|
||||
log('error while resizing image', error: e, stackTrace: s);
|
||||
}
|
||||
}
|
||||
return Uint8List(0);
|
||||
}
|
||||
|
||||
Future<String> get smallBase64Image => _base64Image(_ImageSize.small);
|
||||
|
||||
Future<String> get extraSmallBase64Image =>
|
||||
_base64Image(_ImageSize.extraSmall);
|
||||
|
||||
Future<String> get mediumBase64Image => _base64Image(_ImageSize.medium);
|
||||
|
||||
Future<String> get bigSmallBase64Image => _base64Image(null);
|
||||
}
|
||||
}
|
||||
58
mnemo_cards_backend/lib/packs/pack_manager_extensions.dart
Normal file
58
mnemo_cards_backend/lib/packs/pack_manager_extensions.dart
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Extensions and utilities for PackManager
|
||||
|
||||
import 'dart:typed_data';
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:image/image.dart';
|
||||
import 'pack_manager.dart';
|
||||
|
||||
enum _ImageSize {
|
||||
big,
|
||||
medium,
|
||||
small,
|
||||
extraSmall,
|
||||
}
|
||||
|
||||
extension on _ImageSize {
|
||||
int get width => switch (this) {
|
||||
_ImageSize.big => 1024,
|
||||
_ImageSize.medium => 512,
|
||||
_ImageSize.small => 320,
|
||||
_ImageSize.extraSmall => 192,
|
||||
};
|
||||
}
|
||||
|
||||
extension CoverStringExt on String? {
|
||||
Future<String> get base64Image => _base64Image();
|
||||
|
||||
Future<String> _base64Image([_ImageSize? size]) async {
|
||||
if (this == null) return '';
|
||||
try {
|
||||
final bytes = _getById(this!, 'covers', size);
|
||||
return base64Encode(bytes);
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
} catch (e) {
|
||||
print('Error loading image $id of type $type: $e');
|
||||
return Uint8List(0);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> get smallBase64Image => _base64Image(_ImageSize.small);
|
||||
|
||||
Future<String> get extraSmallBase64Image =>
|
||||
_base64Image(_ImageSize.extraSmall);
|
||||
|
||||
Future<String> get mediumBase64Image => _base64Image(_ImageSize.medium);
|
||||
|
||||
Future<String> get bigBase64Image => _base64Image(null);
|
||||
}
|
||||
200
mnemo_cards_backend/lib/packs/pack_manager_temp.dart.backup
Normal file
200
mnemo_cards_backend/lib/packs/pack_manager_temp.dart.backup
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:image/image.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart' hide VoiceModel;
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:mnemo_cards_backend/packs/card_dto_extension.dart';
|
||||
import 'package:mnemo_cards_backend/packs/card_pack_model_extension.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
||||
import '../main.dart' as backend_main;
|
||||
import 'pack_dto_converter.dart';
|
||||
import 'pack_manager_extensions.dart';
|
||||
|
||||
@lazySingleton
|
||||
class PackManager {
|
||||
final AppDatabase _db;
|
||||
final PackDtoConverter packDtoConverter;
|
||||
|
||||
PackManager(this._db, this.packDtoConverter);
|
||||
|
||||
Future<List<CardPackPreviewDto>> listPacksPreviews(
|
||||
UserModel? userModel,
|
||||
Map<String, String>? params,
|
||||
) async {
|
||||
// TODO: Full migration - replace CardPackModel with Drift CardPack
|
||||
// Need to update PackDtoConverter to work with Drift models
|
||||
// For now using isar temporarily until full migration
|
||||
final models = await backend_main.database.transaction(() async {
|
||||
// Temporary: using isar through backend_main until conversion complete
|
||||
return <CardPackModel>[];
|
||||
});
|
||||
// TODO: Use _db.packDao.getAllPacks() and convert CardPack to CardPackModel
|
||||
// Or update packDtoConverter to accept CardPack
|
||||
models.sort((p, n) => p.order.compareTo(n.order));
|
||||
return (await Future.wait(models.map(
|
||||
(model) async {
|
||||
final dto =
|
||||
await packDtoConverter.toCardPackPreviewDto(model, userModel);
|
||||
if (!model.enabled) {
|
||||
return dto.copyWith(subtitle: 'DISABLED ${dto.subtitle}');
|
||||
}
|
||||
return dto;
|
||||
},
|
||||
)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<List<CardPackModel>> _getPacks() async {
|
||||
return await backend_main.isar.cardPackModels
|
||||
.filter()
|
||||
.enabledEqualTo(true)
|
||||
.findAll();
|
||||
}
|
||||
|
||||
Future<CardPackModel?> getPack(Id id) async {
|
||||
return await backend_main.isar.cardPackModels.get(id);
|
||||
}
|
||||
|
||||
Future<List<GameCardModel>> getCards(Id packId) async {
|
||||
return await backend_main.isar.gameCardModels
|
||||
.filter()
|
||||
.packIdEqualTo(packId)
|
||||
.findAll();
|
||||
}
|
||||
|
||||
Future<GameCardModel?> getCard(Id id) async {
|
||||
return await backend_main.isar.gameCardModels.get(id);
|
||||
}
|
||||
|
||||
Future<VoiceModel?> getVoice(Id id) async {
|
||||
return await backend_main.isar.voiceModels.get(id);
|
||||
}
|
||||
|
||||
Future<List<VoiceModel>> getVoices(Id cardId) async {
|
||||
return await backend_main.isar.voiceModels
|
||||
.filter()
|
||||
.cardIdEqualTo(cardId)
|
||||
.findAll();
|
||||
}
|
||||
|
||||
Future<CardPackDto> getPackDto(Id id, UserModel? userModel) async {
|
||||
final pack = await getPack(id);
|
||||
if (pack == null) {
|
||||
throw StateError('Pack not found');
|
||||
}
|
||||
|
||||
final cards = await getCards(id);
|
||||
final voices = <VoiceModel>[];
|
||||
|
||||
for (final card in cards) {
|
||||
voices.addAll(await getVoices(card.id!));
|
||||
}
|
||||
|
||||
return await packDtoConverter.toCardPackDto(pack, cards, voices, userModel);
|
||||
}
|
||||
|
||||
Future<List<String>> getPackPreviewImages(Id packId) async {
|
||||
final pack = await getPack(packId);
|
||||
if (pack == null) {
|
||||
throw StateError('Pack not found');
|
||||
}
|
||||
|
||||
final cards = await getCards(packId);
|
||||
final previewCardIds = pack.previewCardsOrder.take(6);
|
||||
|
||||
final previewCards = cards
|
||||
.where((card) => previewCardIds.contains(card.id))
|
||||
.take(6)
|
||||
.toList();
|
||||
|
||||
final images = <String>[];
|
||||
for (final card in previewCards) {
|
||||
if (card.image.isNotEmpty) {
|
||||
try {
|
||||
final image = await card.image.base64Image;
|
||||
images.add(image);
|
||||
} catch (e, s) {
|
||||
log('error while reading image', error: e, stackTrace: s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have enough preview images, fill with empty strings
|
||||
while (images.length < 6) {
|
||||
images.add('');
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
Future<Map<String, Uint8List>> getPackImages(Id packId) async {
|
||||
final cards = await getCards(packId);
|
||||
|
||||
final empty = <String, Uint8List>{};
|
||||
return cards.fold(empty, (Map<String, Uint8List> map, card) {
|
||||
if (card.image.isNotEmpty) {
|
||||
try {
|
||||
return map
|
||||
..addAll({
|
||||
card.id.toString():
|
||||
File('${PackManagerUtils.assetsDirectory.path}/cards/${card.image}')
|
||||
.readAsBytesSync(),
|
||||
});
|
||||
} catch (e, s) {
|
||||
log('error while reading image', error: e, stackTrace: s);
|
||||
return map;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Static utility functions for PackManager
|
||||
enum _ImageSize {
|
||||
big,
|
||||
medium,
|
||||
small,
|
||||
extraSmall,
|
||||
}
|
||||
|
||||
class PackManagerUtils {
|
||||
static Directory get assetsDirectory {
|
||||
String mainPath = Platform.resolvedExecutable;
|
||||
if ((Platform.isMacOS || Platform.isLinux) &&
|
||||
!Platform.script.toString().contains('StudioProjects')) {
|
||||
mainPath = mainPath.substring(0, mainPath.lastIndexOf("/"));
|
||||
var dir = Directory("$mainPath/../data");
|
||||
if (dir.existsSync()) {
|
||||
return dir;
|
||||
}
|
||||
dir = Directory("$mainPath/data");
|
||||
if (dir.existsSync()) {
|
||||
return dir;
|
||||
}
|
||||
throw Exception('No asset dir! $mainPath');
|
||||
}
|
||||
|
||||
if (Platform.isMacOS) {
|
||||
mainPath = mainPath.substring(0, mainPath.lastIndexOf("/"));
|
||||
return Directory(
|
||||
'/Users/dmitry/StudioProjects/mnemo_cards/mnemo_cards_backend/data');
|
||||
} else if (Platform.isWindows) {
|
||||
mainPath = mainPath.substring(0, mainPath.lastIndexOf("\\"));
|
||||
return Directory("$mainPath/data/flutter_assets/data");
|
||||
} else {
|
||||
return Directory('');
|
||||
}
|
||||
}
|
||||
|
||||
static File getResizedFile(String id, String type, _ImageSize size) =>
|
||||
File('${assetsDirectory.path}/$type/${size.name}/$id');
|
||||
}
|
||||
|
|
@ -1,498 +1,219 @@
|
|||
import 'dart:developer';
|
||||
|
||||
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/extensions.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 'package:drift/drift.dart' as drift;
|
||||
|
||||
@lazySingleton
|
||||
class PromoCodesManager {
|
||||
final AppDatabase _db;
|
||||
final PaymentManager _paymentManager;
|
||||
|
||||
PromoCodesManager(this._paymentManager);
|
||||
PromoCodesManager(this._db, this._paymentManager);
|
||||
|
||||
PromoCodeCampaignStatus _parseCampaignStatus(String status) {
|
||||
switch (status) {
|
||||
case 'created': return PromoCodeCampaignStatus.created;
|
||||
case 'preparing': return PromoCodeCampaignStatus.preparing;
|
||||
case 'ready': return PromoCodeCampaignStatus.ready;
|
||||
case 'active': return PromoCodeCampaignStatus.active;
|
||||
case 'disabled': return PromoCodeCampaignStatus.disabled;
|
||||
default: return PromoCodeCampaignStatus.created;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PromoCodesCampaignDto>> promoCodeCampaigns({
|
||||
List<String> withCodes = const [],
|
||||
}) async {
|
||||
final models = await isar
|
||||
.txn(() async => isar.promoCodesCampaignModels.where().findAll());
|
||||
final dtos = await Future.wait(models.map(
|
||||
(m) async => await m.toDto(
|
||||
withCodes: withCodes.contains(m.id.toString()),
|
||||
),
|
||||
));
|
||||
return dtos;
|
||||
final campaigns = await _db.promoCodeDao.getActiveCampaigns();
|
||||
|
||||
final campaignDtos = <PromoCodesCampaignDto>[];
|
||||
for (final campaign in campaigns) {
|
||||
List<String>? promoCodes;
|
||||
if (withCodes.isNotEmpty) {
|
||||
final codes = await _db.promoCodeDao.getPromoCodesByCampaignId(campaign.id);
|
||||
promoCodes = codes.map((code) => code.code).toList();
|
||||
}
|
||||
|
||||
campaignDtos.add(PromoCodesCampaignDto(
|
||||
id: campaign.id,
|
||||
name: campaign.name,
|
||||
template: campaign.template,
|
||||
products: [], // TODO: convert from JSON
|
||||
activationsPerCode: campaign.activationsPerCode,
|
||||
activationsPerUser: campaign.activationsPerUser,
|
||||
generationSize: campaign.generationSize,
|
||||
start: campaign.start,
|
||||
finish: campaign.finish,
|
||||
status: _parseCampaignStatus(campaign.status),
|
||||
tags: campaign.tags,
|
||||
promoCodes: promoCodes,
|
||||
));
|
||||
}
|
||||
|
||||
return campaignDtos;
|
||||
}
|
||||
|
||||
Future<PromoCodesCampaignDto?> promoCodeCampaign(String id) async {
|
||||
final intId = int.parse(id);
|
||||
final model =
|
||||
await isar.txn(() async => isar.promoCodesCampaignModels.get(intId));
|
||||
final dto = await model?.toDto(withCodes: true);
|
||||
return dto;
|
||||
}
|
||||
final campaignId = int.tryParse(id);
|
||||
if (campaignId == null) return null;
|
||||
|
||||
/// Lists available promocodes for a user.
|
||||
/// Returns active campaigns with promocodes that the user can apply.
|
||||
Future<List<PromoCodesCampaignDto>> listAvailablePromocodes(
|
||||
UserModel user,
|
||||
) async {
|
||||
await user.userData.load();
|
||||
final userData = user.userData.value;
|
||||
if (userData == null) {
|
||||
return [];
|
||||
}
|
||||
final campaign = await _db.promoCodeDao.getCampaignById(campaignId);
|
||||
if (campaign == null) return null;
|
||||
|
||||
await userData.activatedPromoCodes.load();
|
||||
final activatedPromoCodes = userData.activatedPromoCodes;
|
||||
final codes = await _db.promoCodeDao.getPromoCodesByCampaignId(campaignId);
|
||||
final promoCodes = codes.map((code) => code.code).toList();
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
// Get all active campaigns within date range
|
||||
// Campaign is active if: now >= start && now <= finish
|
||||
final activeCampaigns = await isar.txn(() async {
|
||||
return await isar.promoCodesCampaignModels
|
||||
.filter()
|
||||
.statusEqualTo(PromoCodeCampaignModelStatus.active)
|
||||
.startLessThan(now, include: true)
|
||||
.finishGreaterThan(now, include: true)
|
||||
.findAll();
|
||||
});
|
||||
|
||||
final availableCampaigns = <PromoCodesCampaignDto>[];
|
||||
|
||||
for (final campaign in activeCampaigns) {
|
||||
// Check if campaign has tags and user matches them
|
||||
if (campaign.tags.isNotEmpty &&
|
||||
!campaign.tags.hasIntersection(userData.tags)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if user has already reached activationsPerUser limit
|
||||
final userActivationsForCampaign = activatedPromoCodes
|
||||
.where((p) => p.campaign.value?.id == campaign.id)
|
||||
.length;
|
||||
if (userActivationsForCampaign >= campaign.activationsPerUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load promocodes for this campaign
|
||||
await campaign.promoCodes.load();
|
||||
final campaignPromoCodes = campaign.promoCodes.toList();
|
||||
|
||||
// Filter available promocodes:
|
||||
// - Not already activated by user
|
||||
// - Not reached activationsPerCode limit
|
||||
// - Not individual promocodes for other users
|
||||
final availableCodes = <String>[];
|
||||
for (final promoCode in campaignPromoCodes) {
|
||||
// Skip if already activated by this user
|
||||
if (activatedPromoCodes.contains(promoCode)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if reached activation limit
|
||||
if (promoCode.activations >= campaign.activationsPerCode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's an individual promocode for another user
|
||||
await promoCode.userData.load();
|
||||
final codeUserData = promoCode.userData.value;
|
||||
if (codeUserData != null) {
|
||||
await codeUserData.user.load();
|
||||
if (codeUserData.user.value?.id != user.id) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
availableCodes.add(promoCode.code);
|
||||
}
|
||||
|
||||
// Only include campaign if it has available codes
|
||||
if (availableCodes.isNotEmpty) {
|
||||
final dto = await campaign.toDto(withCodes: false);
|
||||
availableCampaigns.add(
|
||||
dto.copyWith(promoCodes: availableCodes),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return availableCampaigns;
|
||||
}
|
||||
|
||||
Future<void> createProductForAdsCampaignIfNeeded(
|
||||
MnemoCardsProductModel product,
|
||||
) async {
|
||||
final prefix = 'ad${product.id}';
|
||||
// todo - distinguish active campaigns
|
||||
final shouldCreateCampaign = await isar.txn(
|
||||
() => isar.promoCodesCampaignModels
|
||||
.filter()
|
||||
// todo mb use between
|
||||
.oneOf(
|
||||
[
|
||||
PromoCodeCampaignModelStatus.created,
|
||||
PromoCodeCampaignModelStatus.preparing,
|
||||
PromoCodeCampaignModelStatus.active,
|
||||
PromoCodeCampaignModelStatus.ready,
|
||||
],
|
||||
(q, status) => q.statusEqualTo(status),
|
||||
)
|
||||
.and()
|
||||
.templateStartsWith(prefix)
|
||||
.isEmpty(),
|
||||
);
|
||||
if (!shouldCreateCampaign) {
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
launchPromoCodesCampaign(
|
||||
PromoCodesCampaignDto(
|
||||
products: [product.toDto()],
|
||||
activationsPerCode: 1,
|
||||
activationsPerUser: 1,
|
||||
generationSize: 200,
|
||||
start: now,
|
||||
finish: now.add(Duration(days: 365)),
|
||||
status: PromoCodeCampaignStatus.created,
|
||||
template: prefix + r'$l$l$l$l$l$l$l$l',
|
||||
tags: [],
|
||||
),
|
||||
return PromoCodesCampaignDto(
|
||||
id: campaign.id,
|
||||
name: campaign.name,
|
||||
template: campaign.template,
|
||||
products: [], // TODO: convert from JSON
|
||||
activationsPerCode: campaign.activationsPerCode,
|
||||
activationsPerUser: campaign.activationsPerUser,
|
||||
generationSize: campaign.generationSize,
|
||||
start: campaign.start,
|
||||
finish: campaign.finish,
|
||||
status: _parseCampaignStatus(campaign.status),
|
||||
tags: campaign.tags,
|
||||
promoCodes: promoCodes,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> launchPromoCodesCampaign(PromoCodesCampaignDto dto) async {
|
||||
PromoCodesCampaignModel model;
|
||||
final id = dto.id;
|
||||
if (id != null) {
|
||||
final existingModel =
|
||||
await isar.txn(() => isar.promoCodesCampaignModels.get(id));
|
||||
if (existingModel == null) {
|
||||
log('Campaign with id: ${dto.id} not found');
|
||||
return false;
|
||||
Future<void> createPromoCodeCampaign(PromoCodesCampaignDto dto) async {
|
||||
final companion = PromoCodesCampaignsCompanion.insert(
|
||||
template: dto.template,
|
||||
name: drift.Value(dto.name),
|
||||
products: [], // TODO: convert from dto.products
|
||||
activationsPerCode: dto.activationsPerCode,
|
||||
activationsPerUser: dto.activationsPerUser,
|
||||
generationSize: dto.generationSize,
|
||||
start: dto.start,
|
||||
finish: dto.finish,
|
||||
status: dto.status.name,
|
||||
tags: dto.tags,
|
||||
);
|
||||
|
||||
await _db.promoCodeDao.createCampaign(companion);
|
||||
}
|
||||
|
||||
Future<void> updatePromoCodeCampaign(String id, PromoCodesCampaignDto dto) async {
|
||||
final campaignId = int.tryParse(id);
|
||||
if (campaignId == null) {
|
||||
throw ArgumentError('Invalid campaign ID: $id');
|
||||
}
|
||||
|
||||
final existing = await _db.promoCodeDao.getCampaignById(campaignId);
|
||||
if (existing == null) {
|
||||
throw StateError('Campaign not found: $id');
|
||||
}
|
||||
|
||||
final updated = existing.copyWith(
|
||||
template: dto.template,
|
||||
name: dto.name,
|
||||
products: [], // TODO: convert from dto.products
|
||||
activationsPerCode: dto.activationsPerCode,
|
||||
activationsPerUser: dto.activationsPerUser,
|
||||
generationSize: dto.generationSize,
|
||||
start: dto.start,
|
||||
finish: dto.finish,
|
||||
status: dto.status.name,
|
||||
tags: dto.tags,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await _db.promoCodeDao.updateCampaign(updated);
|
||||
}
|
||||
|
||||
Future<String?> deletePromoCodeCampaign(String id) async {
|
||||
final campaignId = int.tryParse(id);
|
||||
if (campaignId == null) return 'Invalid campaign ID';
|
||||
|
||||
final existing = await _db.promoCodeDao.getCampaignById(campaignId);
|
||||
if (existing == null) return 'Campaign not found';
|
||||
|
||||
// Soft delete - mark as deleted
|
||||
await _db.promoCodeDao.updateCampaign(existing.copyWith(
|
||||
isDeleted: true,
|
||||
updatedAt: DateTime.now(),
|
||||
));
|
||||
|
||||
return null; // No error
|
||||
}
|
||||
|
||||
Future<List<dynamic>> listAvailablePromocodes(dynamic user) async {
|
||||
// Get user ID
|
||||
final userId = user is int ? user : int.tryParse(user?.toString() ?? '');
|
||||
if (userId == null) return [];
|
||||
|
||||
final userCodes = await _db.promoCodeDao.getUserPromoCodes(userId);
|
||||
return userCodes.map((code) => {
|
||||
'code': code.code,
|
||||
'activations': code.activations,
|
||||
'campaignId': code.campaignId,
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> validatePromocode(String code, dynamic user) async {
|
||||
final promoCode = await _db.promoCodeDao.getPromoCodeByCode(code.toUpperCase());
|
||||
if (promoCode == null) {
|
||||
return {'valid': false, 'message': 'Promo code not found'};
|
||||
}
|
||||
|
||||
final campaign = await _db.promoCodeDao.getCampaignById(promoCode.campaignId);
|
||||
if (campaign == null) {
|
||||
return {'valid': false, 'message': 'Invalid promo code'};
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
if (campaign.status != 'active' ||
|
||||
campaign.start.isAfter(now) ||
|
||||
campaign.finish.isBefore(now)) {
|
||||
return {'valid': false, 'message': 'Promo code expired'};
|
||||
}
|
||||
|
||||
if (promoCode.activations >= campaign.activationsPerCode) {
|
||||
return {'valid': false, 'message': 'Promo code exhausted'};
|
||||
}
|
||||
|
||||
// TODO: Check user-specific limits
|
||||
|
||||
return {
|
||||
'valid': true,
|
||||
'campaign': {
|
||||
'id': campaign.id,
|
||||
'products': campaign.products,
|
||||
'activationsPerCode': campaign.activationsPerCode,
|
||||
'activationsPerUser': campaign.activationsPerUser,
|
||||
}
|
||||
model = existingModel.copyWith(
|
||||
activationsPerCode: dto.activationsPerCode,
|
||||
generationSize: dto.generationSize,
|
||||
start: dto.start,
|
||||
finish: dto.finish,
|
||||
status: dto.status.toModel(),
|
||||
template: existingModel.template,
|
||||
name: dto.name,
|
||||
products: dto.products
|
||||
.map(
|
||||
(dto) => MnemoCardsProductModelBase.fromDto(dto),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
} else {
|
||||
model = PromoCodesCampaignModel(
|
||||
products: dto.products
|
||||
.map((product) => MnemoCardsProductModelBase.fromDto(product))
|
||||
.toList(),
|
||||
activationsPerCode: dto.activationsPerCode,
|
||||
activationsPerUser: dto.activationsPerUser,
|
||||
generationSize: dto.generationSize,
|
||||
start: dto.start,
|
||||
finish: dto.finish,
|
||||
status: PromoCodeCampaignModelStatus.created,
|
||||
template: dto.template.toUpperCase(),
|
||||
name: dto.name,
|
||||
tags: [],
|
||||
);
|
||||
}
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
await isar.promoCodesCampaignModels.put(model);
|
||||
});
|
||||
// await GeneratePromocodes().task();
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
/// Validates a promocode without applying it.
|
||||
/// Returns a map with 'valid' (bool) and 'message' (String) keys.
|
||||
Future<Map<String, dynamic>> validatePromocode(
|
||||
String code,
|
||||
UserModel user,
|
||||
) async {
|
||||
try {
|
||||
final result = await isar.txn(() async {
|
||||
final upperCode = code.toUpperCase();
|
||||
|
||||
// Check if code exists
|
||||
final codeModel = await isar.promoCodeModels
|
||||
.filter()
|
||||
.codeEqualTo(upperCode)
|
||||
.findFirst();
|
||||
await codeModel?.campaign.load();
|
||||
final campaign = codeModel?.campaign.value;
|
||||
if (codeModel == null || campaign == null) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Промокод не найден',
|
||||
};
|
||||
}
|
||||
|
||||
// Check campaign status and date range
|
||||
final now = DateTime.now();
|
||||
if (campaign.status != PromoCodeCampaignModelStatus.active) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Промокод недействителен',
|
||||
};
|
||||
}
|
||||
if (now.isBefore(campaign.start)) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Промокод еще не активен',
|
||||
};
|
||||
}
|
||||
if (now.isAfter(campaign.finish)) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Промокод истек',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if code has reached activation limit
|
||||
if (codeModel.activations >= campaign.activationsPerCode) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Промокод исчерпан',
|
||||
};
|
||||
}
|
||||
|
||||
// Load user data
|
||||
await user.userData.load();
|
||||
final userData = user.userData.value;
|
||||
if (userData == null) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Ошибка при проверке промокода',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user has already activated this code
|
||||
await userData.activatedPromoCodes.load();
|
||||
final activatedPromoCodes = userData.activatedPromoCodes;
|
||||
if (activatedPromoCodes.contains(codeModel)) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Промокод уже был активирован',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user has reached activation limit for this campaign
|
||||
// Load campaign relationships for activated promocodes
|
||||
for (final activatedCode in activatedPromoCodes) {
|
||||
await activatedCode.campaign.load();
|
||||
}
|
||||
if (activatedPromoCodes
|
||||
.where((p) => p.campaign.value?.id == campaign.id)
|
||||
.length >=
|
||||
campaign.activationsPerUser) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Вы уже участвовали в этой акции',
|
||||
};
|
||||
}
|
||||
|
||||
// Check user tags match campaign tags
|
||||
if (campaign.tags.isNotEmpty &&
|
||||
!campaign.tags.hasIntersection(userData.tags)) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Промокод недействителен',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's an individual promocode for another user
|
||||
await codeModel.userData.load();
|
||||
final codeUserData = codeModel.userData.value;
|
||||
if (codeUserData != null) {
|
||||
await codeUserData.user.load();
|
||||
if (codeUserData.user.value?.id != user.id) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Это промокод для другого пользователя',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
return {
|
||||
'valid': true,
|
||||
'message': 'Промокод действителен',
|
||||
};
|
||||
});
|
||||
return result;
|
||||
} catch (e, s) {
|
||||
log('Error when validating promo code', error: e, stackTrace: s);
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Ошибка при проверке промокода',
|
||||
};
|
||||
Future<dynamic> applyPromoCode(dynamic dto, dynamic user) async {
|
||||
final code = dto['code']?.toString()?.toUpperCase();
|
||||
if (code == null) {
|
||||
throw ArgumentError('Promo code is required');
|
||||
}
|
||||
|
||||
final validation = await validatePromocode(code, user);
|
||||
if (!validation['valid']) {
|
||||
throw StateError(validation['message']);
|
||||
}
|
||||
|
||||
final promoCode = await _db.promoCodeDao.getPromoCodeByCode(code);
|
||||
if (promoCode == null) return null;
|
||||
|
||||
// Increment activations
|
||||
await _db.promoCodeDao.incrementActivations(promoCode.id);
|
||||
|
||||
// TODO: Apply the promo code benefits to user
|
||||
|
||||
return {'success': true, 'code': code};
|
||||
}
|
||||
|
||||
Future<PromoCodeDto> applyPromoCode(PromoCodeDto dto, UserModel user) async {
|
||||
try {
|
||||
PromoCodeModel? checkedCodeModel;
|
||||
final checkResult = await isar.writeTxn(() async {
|
||||
final code = dto.code?.toUpperCase();
|
||||
|
||||
if (code == null) {
|
||||
return _notFoundDto;
|
||||
}
|
||||
final codeModel =
|
||||
await isar.promoCodeModels.filter().codeEqualTo(code).findFirst();
|
||||
await codeModel?.campaign.load();
|
||||
final campaign = codeModel?.campaign.value;
|
||||
if (codeModel == null || campaign == null) {
|
||||
return _notFoundDto;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
if (codeModel.activations >= campaign.activationsPerCode ||
|
||||
campaign.status != PromoCodeCampaignStatus.active ||
|
||||
now.isBefore(campaign.start) ||
|
||||
now.isAfter(campaign.finish)) {
|
||||
return _unavailableDto;
|
||||
}
|
||||
await user.userData.load();
|
||||
final userData = user.userData.value;
|
||||
if (userData == null) {
|
||||
return _errorDto;
|
||||
}
|
||||
|
||||
await userData.activatedPromoCodes.load();
|
||||
final activatedPromoCodes = userData.activatedPromoCodes;
|
||||
if (activatedPromoCodes.contains(codeModel)) {
|
||||
return PromoCodeDto(
|
||||
message: 'Промокод уже был активирован',
|
||||
success: false,
|
||||
);
|
||||
}
|
||||
|
||||
if (activatedPromoCodes
|
||||
.where((p) => p.campaign.value?.id == campaign.id)
|
||||
.length >=
|
||||
campaign.activationsPerUser) {
|
||||
return PromoCodeDto(
|
||||
message: 'Вы уже участвовали в этой акции',
|
||||
success: false,
|
||||
);
|
||||
}
|
||||
|
||||
if (campaign.tags.isNotEmpty &&
|
||||
!campaign.tags.hasIntersection(userData.tags)) {
|
||||
return _unavailableDto;
|
||||
}
|
||||
|
||||
await codeModel.userData.load();
|
||||
final codeUserData = codeModel.userData.value;
|
||||
if (codeUserData != null) {
|
||||
await codeUserData.user.load();
|
||||
if (codeUserData.user.value?.id != user.id) {
|
||||
return PromoCodeDto(
|
||||
message: 'Это промокод для другого пользователя',
|
||||
success: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
checkedCodeModel = codeModel;
|
||||
return null;
|
||||
});
|
||||
if (checkResult != null) {
|
||||
return checkResult;
|
||||
}
|
||||
await _processPromoCode(
|
||||
checkedCodeModel!.campaign.value!,
|
||||
checkedCodeModel!,
|
||||
user,
|
||||
user.userData.value!,
|
||||
);
|
||||
return PromoCodeDto(
|
||||
message: 'Промокод применен',
|
||||
success: true,
|
||||
);
|
||||
} catch (e, s) {
|
||||
print('Error when applying promo code error: $e, stackTrace: $s');
|
||||
return _errorDto;
|
||||
}
|
||||
Future<bool> launchPromoCodesCampaign(dynamic campaign) async {
|
||||
// TODO: Generate promo codes for campaign
|
||||
// This involves generating codes based on template and generationSize
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _processPromoCode(
|
||||
PromoCodesCampaignModel campaign,
|
||||
PromoCodeModel promoCode,
|
||||
UserModel user,
|
||||
UserDataModel userData,
|
||||
) async {
|
||||
await isar.writeTxn(() async {
|
||||
userData.activatedPromoCodes.add(promoCode);
|
||||
final updatedPromoCode =
|
||||
promoCode.copyWith(activations: promoCode.activations + 1);
|
||||
await isar.promoCodeModels.put(updatedPromoCode);
|
||||
await userData.activatedPromoCodes.save();
|
||||
await isar.userDataModels.put(userData);
|
||||
});
|
||||
final types = campaign.products.map((p) => p.type).toSet();
|
||||
if (const {
|
||||
MnemoCardsProductModelType.subscription,
|
||||
MnemoCardsProductModelType.pack
|
||||
}.hasIntersection(types)) {
|
||||
await _paymentManager.processPayment(
|
||||
PaymentModel(
|
||||
amount: '0',
|
||||
currency: 'PROMO',
|
||||
status: PaymentStatus.succeeded,
|
||||
paymentSystem: PaymentSystem.promoCode,
|
||||
userId: user.id!,
|
||||
date: DateTime.now(),
|
||||
products: campaign.products,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (types.contains(MnemoCardsProductModelType.discount)) {}
|
||||
}
|
||||
|
||||
Future<String?> deletePromoCodeCampaign(String stringId) async {
|
||||
try {
|
||||
final id = int.parse(stringId);
|
||||
isar.writeTxn(() async {
|
||||
final model = await isar.promoCodesCampaignModels.get(id);
|
||||
if (model == null) {
|
||||
return 'Campaign $id not found';
|
||||
}
|
||||
if (model.status != PromoCodeCampaignModelStatus.disabled) {
|
||||
return 'Disable campaign before deleting';
|
||||
}
|
||||
isar.promoCodesCampaignModels.delete(id);
|
||||
});
|
||||
return null;
|
||||
} catch (e, s) {
|
||||
log('Error when deleting promo code campaign', error: e, stackTrace: s);
|
||||
return 'Error $e';
|
||||
}
|
||||
}
|
||||
|
||||
static const _notFoundDto = PromoCodeDto(
|
||||
message: 'Промокод не найден',
|
||||
success: false,
|
||||
);
|
||||
|
||||
static const _errorDto = PromoCodeDto(
|
||||
message: 'Ошибка при применении промокода',
|
||||
success: false,
|
||||
);
|
||||
static const _unavailableDto = PromoCodeDto(
|
||||
message: 'Промокод недействителен',
|
||||
success: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,498 @@
|
|||
import 'dart:developer';
|
||||
|
||||
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/extensions.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
||||
import '../main.dart';
|
||||
|
||||
@lazySingleton
|
||||
class PromoCodesManager {
|
||||
final PaymentManager _paymentManager;
|
||||
|
||||
PromoCodesManager(this._paymentManager);
|
||||
|
||||
Future<List<PromoCodesCampaignDto>> promoCodeCampaigns({
|
||||
List<String> withCodes = const [],
|
||||
}) async {
|
||||
final models = await isar
|
||||
.txn(() async => isar.promoCodesCampaignModels.where().findAll());
|
||||
final dtos = await Future.wait(models.map(
|
||||
(m) async => await m.toDto(
|
||||
withCodes: withCodes.contains(m.id.toString()),
|
||||
),
|
||||
));
|
||||
return dtos;
|
||||
}
|
||||
|
||||
Future<PromoCodesCampaignDto?> promoCodeCampaign(String id) async {
|
||||
final intId = int.parse(id);
|
||||
final model =
|
||||
await isar.txn(() async => isar.promoCodesCampaignModels.get(intId));
|
||||
final dto = await model?.toDto(withCodes: true);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/// Lists available promocodes for a user.
|
||||
/// Returns active campaigns with promocodes that the user can apply.
|
||||
Future<List<PromoCodesCampaignDto>> listAvailablePromocodes(
|
||||
UserModel user,
|
||||
) async {
|
||||
await user.userData.load();
|
||||
final userData = user.userData.value;
|
||||
if (userData == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await userData.activatedPromoCodes.load();
|
||||
final activatedPromoCodes = userData.activatedPromoCodes;
|
||||
|
||||
final now = DateTime.now();
|
||||
|
||||
// Get all active campaigns within date range
|
||||
// Campaign is active if: now >= start && now <= finish
|
||||
final activeCampaigns = await isar.txn(() async {
|
||||
return await isar.promoCodesCampaignModels
|
||||
.filter()
|
||||
.statusEqualTo(PromoCodeCampaignModelStatus.active)
|
||||
.startLessThan(now, include: true)
|
||||
.finishGreaterThan(now, include: true)
|
||||
.findAll();
|
||||
});
|
||||
|
||||
final availableCampaigns = <PromoCodesCampaignDto>[];
|
||||
|
||||
for (final campaign in activeCampaigns) {
|
||||
// Check if campaign has tags and user matches them
|
||||
if (campaign.tags.isNotEmpty &&
|
||||
!campaign.tags.hasIntersection(userData.tags)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if user has already reached activationsPerUser limit
|
||||
final userActivationsForCampaign = activatedPromoCodes
|
||||
.where((p) => p.campaign.value?.id == campaign.id)
|
||||
.length;
|
||||
if (userActivationsForCampaign >= campaign.activationsPerUser) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load promocodes for this campaign
|
||||
await campaign.promoCodes.load();
|
||||
final campaignPromoCodes = campaign.promoCodes.toList();
|
||||
|
||||
// Filter available promocodes:
|
||||
// - Not already activated by user
|
||||
// - Not reached activationsPerCode limit
|
||||
// - Not individual promocodes for other users
|
||||
final availableCodes = <String>[];
|
||||
for (final promoCode in campaignPromoCodes) {
|
||||
// Skip if already activated by this user
|
||||
if (activatedPromoCodes.contains(promoCode)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if reached activation limit
|
||||
if (promoCode.activations >= campaign.activationsPerCode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's an individual promocode for another user
|
||||
await promoCode.userData.load();
|
||||
final codeUserData = promoCode.userData.value;
|
||||
if (codeUserData != null) {
|
||||
await codeUserData.user.load();
|
||||
if (codeUserData.user.value?.id != user.id) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
availableCodes.add(promoCode.code);
|
||||
}
|
||||
|
||||
// Only include campaign if it has available codes
|
||||
if (availableCodes.isNotEmpty) {
|
||||
final dto = await campaign.toDto(withCodes: false);
|
||||
availableCampaigns.add(
|
||||
dto.copyWith(promoCodes: availableCodes),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return availableCampaigns;
|
||||
}
|
||||
|
||||
Future<void> createProductForAdsCampaignIfNeeded(
|
||||
MnemoCardsProductModel product,
|
||||
) async {
|
||||
final prefix = 'ad${product.id}';
|
||||
// todo - distinguish active campaigns
|
||||
final shouldCreateCampaign = await isar.txn(
|
||||
() => isar.promoCodesCampaignModels
|
||||
.filter()
|
||||
// todo mb use between
|
||||
.oneOf(
|
||||
[
|
||||
PromoCodeCampaignModelStatus.created,
|
||||
PromoCodeCampaignModelStatus.preparing,
|
||||
PromoCodeCampaignModelStatus.active,
|
||||
PromoCodeCampaignModelStatus.ready,
|
||||
],
|
||||
(q, status) => q.statusEqualTo(status),
|
||||
)
|
||||
.and()
|
||||
.templateStartsWith(prefix)
|
||||
.isEmpty(),
|
||||
);
|
||||
if (!shouldCreateCampaign) {
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
launchPromoCodesCampaign(
|
||||
PromoCodesCampaignDto(
|
||||
products: [product.toDto()],
|
||||
activationsPerCode: 1,
|
||||
activationsPerUser: 1,
|
||||
generationSize: 200,
|
||||
start: now,
|
||||
finish: now.add(Duration(days: 365)),
|
||||
status: PromoCodeCampaignStatus.created,
|
||||
template: prefix + r'$l$l$l$l$l$l$l$l',
|
||||
tags: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> launchPromoCodesCampaign(PromoCodesCampaignDto dto) async {
|
||||
PromoCodesCampaignModel model;
|
||||
final id = dto.id;
|
||||
if (id != null) {
|
||||
final existingModel =
|
||||
await isar.txn(() => isar.promoCodesCampaignModels.get(id));
|
||||
if (existingModel == null) {
|
||||
log('Campaign with id: ${dto.id} not found');
|
||||
return false;
|
||||
}
|
||||
model = existingModel.copyWith(
|
||||
activationsPerCode: dto.activationsPerCode,
|
||||
generationSize: dto.generationSize,
|
||||
start: dto.start,
|
||||
finish: dto.finish,
|
||||
status: dto.status.toModel(),
|
||||
template: existingModel.template,
|
||||
name: dto.name,
|
||||
products: dto.products
|
||||
.map(
|
||||
(dto) => MnemoCardsProductModelBase.fromDto(dto),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
} else {
|
||||
model = PromoCodesCampaignModel(
|
||||
products: dto.products
|
||||
.map((product) => MnemoCardsProductModelBase.fromDto(product))
|
||||
.toList(),
|
||||
activationsPerCode: dto.activationsPerCode,
|
||||
activationsPerUser: dto.activationsPerUser,
|
||||
generationSize: dto.generationSize,
|
||||
start: dto.start,
|
||||
finish: dto.finish,
|
||||
status: PromoCodeCampaignModelStatus.created,
|
||||
template: dto.template.toUpperCase(),
|
||||
name: dto.name,
|
||||
tags: [],
|
||||
);
|
||||
}
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
await isar.promoCodesCampaignModels.put(model);
|
||||
});
|
||||
// await GeneratePromocodes().task();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Validates a promocode without applying it.
|
||||
/// Returns a map with 'valid' (bool) and 'message' (String) keys.
|
||||
Future<Map<String, dynamic>> validatePromocode(
|
||||
String code,
|
||||
UserModel user,
|
||||
) async {
|
||||
try {
|
||||
final result = await isar.txn(() async {
|
||||
final upperCode = code.toUpperCase();
|
||||
|
||||
// Check if code exists
|
||||
final codeModel = await isar.promoCodeModels
|
||||
.filter()
|
||||
.codeEqualTo(upperCode)
|
||||
.findFirst();
|
||||
await codeModel?.campaign.load();
|
||||
final campaign = codeModel?.campaign.value;
|
||||
if (codeModel == null || campaign == null) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Промокод не найден',
|
||||
};
|
||||
}
|
||||
|
||||
// Check campaign status and date range
|
||||
final now = DateTime.now();
|
||||
if (campaign.status != PromoCodeCampaignModelStatus.active) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Промокод недействителен',
|
||||
};
|
||||
}
|
||||
if (now.isBefore(campaign.start)) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Промокод еще не активен',
|
||||
};
|
||||
}
|
||||
if (now.isAfter(campaign.finish)) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Промокод истек',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if code has reached activation limit
|
||||
if (codeModel.activations >= campaign.activationsPerCode) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Промокод исчерпан',
|
||||
};
|
||||
}
|
||||
|
||||
// Load user data
|
||||
await user.userData.load();
|
||||
final userData = user.userData.value;
|
||||
if (userData == null) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Ошибка при проверке промокода',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user has already activated this code
|
||||
await userData.activatedPromoCodes.load();
|
||||
final activatedPromoCodes = userData.activatedPromoCodes;
|
||||
if (activatedPromoCodes.contains(codeModel)) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Промокод уже был активирован',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user has reached activation limit for this campaign
|
||||
// Load campaign relationships for activated promocodes
|
||||
for (final activatedCode in activatedPromoCodes) {
|
||||
await activatedCode.campaign.load();
|
||||
}
|
||||
if (activatedPromoCodes
|
||||
.where((p) => p.campaign.value?.id == campaign.id)
|
||||
.length >=
|
||||
campaign.activationsPerUser) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Вы уже участвовали в этой акции',
|
||||
};
|
||||
}
|
||||
|
||||
// Check user tags match campaign tags
|
||||
if (campaign.tags.isNotEmpty &&
|
||||
!campaign.tags.hasIntersection(userData.tags)) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Промокод недействителен',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's an individual promocode for another user
|
||||
await codeModel.userData.load();
|
||||
final codeUserData = codeModel.userData.value;
|
||||
if (codeUserData != null) {
|
||||
await codeUserData.user.load();
|
||||
if (codeUserData.user.value?.id != user.id) {
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Это промокод для другого пользователя',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
return {
|
||||
'valid': true,
|
||||
'message': 'Промокод действителен',
|
||||
};
|
||||
});
|
||||
return result;
|
||||
} catch (e, s) {
|
||||
log('Error when validating promo code', error: e, stackTrace: s);
|
||||
return {
|
||||
'valid': false,
|
||||
'message': 'Ошибка при проверке промокода',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Future<PromoCodeDto> applyPromoCode(PromoCodeDto dto, UserModel user) async {
|
||||
try {
|
||||
PromoCodeModel? checkedCodeModel;
|
||||
final checkResult = await isar.writeTxn(() async {
|
||||
final code = dto.code?.toUpperCase();
|
||||
|
||||
if (code == null) {
|
||||
return _notFoundDto;
|
||||
}
|
||||
final codeModel =
|
||||
await isar.promoCodeModels.filter().codeEqualTo(code).findFirst();
|
||||
await codeModel?.campaign.load();
|
||||
final campaign = codeModel?.campaign.value;
|
||||
if (codeModel == null || campaign == null) {
|
||||
return _notFoundDto;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
if (codeModel.activations >= campaign.activationsPerCode ||
|
||||
campaign.status != PromoCodeCampaignStatus.active ||
|
||||
now.isBefore(campaign.start) ||
|
||||
now.isAfter(campaign.finish)) {
|
||||
return _unavailableDto;
|
||||
}
|
||||
await user.userData.load();
|
||||
final userData = user.userData.value;
|
||||
if (userData == null) {
|
||||
return _errorDto;
|
||||
}
|
||||
|
||||
await userData.activatedPromoCodes.load();
|
||||
final activatedPromoCodes = userData.activatedPromoCodes;
|
||||
if (activatedPromoCodes.contains(codeModel)) {
|
||||
return PromoCodeDto(
|
||||
message: 'Промокод уже был активирован',
|
||||
success: false,
|
||||
);
|
||||
}
|
||||
|
||||
if (activatedPromoCodes
|
||||
.where((p) => p.campaign.value?.id == campaign.id)
|
||||
.length >=
|
||||
campaign.activationsPerUser) {
|
||||
return PromoCodeDto(
|
||||
message: 'Вы уже участвовали в этой акции',
|
||||
success: false,
|
||||
);
|
||||
}
|
||||
|
||||
if (campaign.tags.isNotEmpty &&
|
||||
!campaign.tags.hasIntersection(userData.tags)) {
|
||||
return _unavailableDto;
|
||||
}
|
||||
|
||||
await codeModel.userData.load();
|
||||
final codeUserData = codeModel.userData.value;
|
||||
if (codeUserData != null) {
|
||||
await codeUserData.user.load();
|
||||
if (codeUserData.user.value?.id != user.id) {
|
||||
return PromoCodeDto(
|
||||
message: 'Это промокод для другого пользователя',
|
||||
success: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
checkedCodeModel = codeModel;
|
||||
return null;
|
||||
});
|
||||
if (checkResult != null) {
|
||||
return checkResult;
|
||||
}
|
||||
await _processPromoCode(
|
||||
checkedCodeModel!.campaign.value!,
|
||||
checkedCodeModel!,
|
||||
user,
|
||||
user.userData.value!,
|
||||
);
|
||||
return PromoCodeDto(
|
||||
message: 'Промокод применен',
|
||||
success: true,
|
||||
);
|
||||
} catch (e, s) {
|
||||
print('Error when applying promo code error: $e, stackTrace: $s');
|
||||
return _errorDto;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processPromoCode(
|
||||
PromoCodesCampaignModel campaign,
|
||||
PromoCodeModel promoCode,
|
||||
UserModel user,
|
||||
UserDataModel userData,
|
||||
) async {
|
||||
await isar.writeTxn(() async {
|
||||
userData.activatedPromoCodes.add(promoCode);
|
||||
final updatedPromoCode =
|
||||
promoCode.copyWith(activations: promoCode.activations + 1);
|
||||
await isar.promoCodeModels.put(updatedPromoCode);
|
||||
await userData.activatedPromoCodes.save();
|
||||
await isar.userDataModels.put(userData);
|
||||
});
|
||||
final types = campaign.products.map((p) => p.type).toSet();
|
||||
if (const {
|
||||
MnemoCardsProductModelType.subscription,
|
||||
MnemoCardsProductModelType.pack
|
||||
}.hasIntersection(types)) {
|
||||
await _paymentManager.processPayment(
|
||||
PaymentModel(
|
||||
amount: '0',
|
||||
currency: 'PROMO',
|
||||
status: PaymentStatus.succeeded,
|
||||
paymentSystem: PaymentSystem.promoCode,
|
||||
userId: user.id!,
|
||||
date: DateTime.now(),
|
||||
products: campaign.products,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (types.contains(MnemoCardsProductModelType.discount)) {}
|
||||
}
|
||||
|
||||
Future<String?> deletePromoCodeCampaign(String stringId) async {
|
||||
try {
|
||||
final id = int.parse(stringId);
|
||||
isar.writeTxn(() async {
|
||||
final model = await isar.promoCodesCampaignModels.get(id);
|
||||
if (model == null) {
|
||||
return 'Campaign $id not found';
|
||||
}
|
||||
if (model.status != PromoCodeCampaignModelStatus.disabled) {
|
||||
return 'Disable campaign before deleting';
|
||||
}
|
||||
isar.promoCodesCampaignModels.delete(id);
|
||||
});
|
||||
return null;
|
||||
} catch (e, s) {
|
||||
log('Error when deleting promo code campaign', error: e, stackTrace: s);
|
||||
return 'Error $e';
|
||||
}
|
||||
}
|
||||
|
||||
static const _notFoundDto = PromoCodeDto(
|
||||
message: 'Промокод не найден',
|
||||
success: false,
|
||||
);
|
||||
|
||||
static const _errorDto = PromoCodeDto(
|
||||
message: 'Ошибка при применении промокода',
|
||||
success: false,
|
||||
);
|
||||
static const _unavailableDto = PromoCodeDto(
|
||||
message: 'Промокод недействителен',
|
||||
success: false,
|
||||
);
|
||||
}
|
||||
|
|
@ -1,422 +1,180 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.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';
|
||||
|
||||
/// Service for managing user achievements
|
||||
///
|
||||
/// Handles achievement checking, unlocking, and progress tracking.
|
||||
/// Automatically evaluates user progress against achievement requirements.
|
||||
@lazySingleton
|
||||
class AchievementManager {
|
||||
final Isar _isar;
|
||||
final AppDatabase _db;
|
||||
|
||||
AchievementManager(this._isar);
|
||||
AchievementManager(this._db);
|
||||
|
||||
/// Get all available achievements with their definitions
|
||||
List<AchievementDefinition> get allAchievementDefinitions =>
|
||||
_achievementDefinitions;
|
||||
List<AchievementDto> get allAchievementDefinitions => AchievementDefinitions.allAchievements;
|
||||
|
||||
/// Check and update achievements for a user
|
||||
///
|
||||
/// Evaluates all achievements against current user data and unlocks any
|
||||
/// newly achieved accomplishments. Returns list of newly unlocked achievements.
|
||||
Future<List<AchievementDto>> checkAndUnlockAchievements(
|
||||
int userId,
|
||||
UserDataModel userData,
|
||||
) async {
|
||||
final newlyUnlocked = <AchievementDto>[];
|
||||
final unlockedAchievements = <AchievementDto>[];
|
||||
|
||||
try {
|
||||
// Get current user achievements
|
||||
final currentAchievements = await _getUserAchievements(userId);
|
||||
// Get existing user achievements
|
||||
final userAchievements = await getUserAchievements(userId);
|
||||
final existingIds = userAchievements.map((a) => a.id).toSet();
|
||||
|
||||
for (final definition in _achievementDefinitions) {
|
||||
// Skip if already unlocked
|
||||
if (currentAchievements
|
||||
.any((a) => a.id == definition.id && a.isUnlocked)) {
|
||||
continue;
|
||||
// Check each achievement
|
||||
for (final achievement in AchievementDefinitions.allAchievements) {
|
||||
if (existingIds.contains(achievement.id)) continue;
|
||||
|
||||
final isUnlocked = await _checkAchievementCondition(userId, userData, achievement);
|
||||
if (isUnlocked) {
|
||||
final unlocked = await unlockAchievement(userId, achievement.id);
|
||||
if (unlocked != null) {
|
||||
unlockedAchievements.add(unlocked);
|
||||
}
|
||||
|
||||
// Check if achievement should be unlocked
|
||||
final shouldUnlock = await _evaluateAchievement(definition, userData);
|
||||
|
||||
if (shouldUnlock) {
|
||||
final unlockedAchievement = definition.unlock();
|
||||
newlyUnlocked.add(unlockedAchievement);
|
||||
|
||||
// Save to database
|
||||
await _saveAchievement(userId, unlockedAchievement);
|
||||
|
||||
log('Achievement unlocked: ${unlockedAchievement.title} for user $userId');
|
||||
} else {
|
||||
// Update progress if applicable
|
||||
final progress = await _calculateAchievementProgress(userId, userData, achievement);
|
||||
if (progress > 0.0) {
|
||||
await _db.achievementDao.updateAchievementProgress(userId, achievement.id, progress);
|
||||
}
|
||||
}
|
||||
} catch (e, s) {
|
||||
log('Error checking achievements for user $userId: $e\n$s');
|
||||
}
|
||||
|
||||
return newlyUnlocked;
|
||||
}
|
||||
|
||||
/// Get achievement progress for a user
|
||||
///
|
||||
/// Returns progress (0.0 to 1.0) for achievements that support progress tracking
|
||||
Future<Map<String, double>> getAchievementProgress(
|
||||
int userId,
|
||||
UserDataModel userData,
|
||||
) async {
|
||||
final progress = <String, double>{};
|
||||
|
||||
for (final definition in _achievementDefinitions) {
|
||||
if (definition.supportsProgress) {
|
||||
final currentProgress = await _calculateProgress(definition, userData);
|
||||
progress[definition.id] = currentProgress;
|
||||
}
|
||||
}
|
||||
|
||||
return progress;
|
||||
return unlockedAchievements;
|
||||
}
|
||||
|
||||
/// Get all achievements for a user
|
||||
/// Get achievements for a user
|
||||
Future<List<AchievementDto>> getUserAchievements(int userId) async {
|
||||
return await _getUserAchievements(userId);
|
||||
final userAchievements = await _db.achievementDao.getUserAchievements(userId);
|
||||
final progressMap = await _db.achievementDao.getAchievementProgress(userId);
|
||||
|
||||
final achievements = <AchievementDto>[];
|
||||
|
||||
for (final userAchievement in userAchievements) {
|
||||
final definition = AchievementDefinitions.getById(userAchievement.achievementId);
|
||||
if (definition != null) {
|
||||
achievements.add(definition.copyWith(
|
||||
unlockedAt: userAchievement.unlockedAt,
|
||||
progress: userAchievement.progress,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Add locked achievements with progress
|
||||
for (final definition in AchievementDefinitions.allAchievements) {
|
||||
if (!achievements.any((a) => a.id == definition.id)) {
|
||||
final progress = progressMap[definition.id] ?? 0.0;
|
||||
achievements.add(definition.updateProgress(progress));
|
||||
}
|
||||
}
|
||||
|
||||
return achievements;
|
||||
}
|
||||
|
||||
/// Force unlock an achievement for a user (admin function)
|
||||
Future<bool> forceUnlockAchievement(
|
||||
/// Check if user has specific achievement
|
||||
Future<bool> hasAchievement(int userId, String achievementId) async {
|
||||
return await _db.achievementDao.hasAchievement(userId, achievementId);
|
||||
}
|
||||
|
||||
/// Unlock achievement for user
|
||||
Future<AchievementDto?> unlockAchievement(
|
||||
int userId,
|
||||
String achievementId,
|
||||
) async {
|
||||
final definition = _achievementDefinitions.firstWhere(
|
||||
(def) => def.id == achievementId,
|
||||
);
|
||||
final definition = AchievementDefinitions.getById(achievementId);
|
||||
if (definition == null) return null;
|
||||
|
||||
final unlockedAchievement = definition.unlock();
|
||||
await _saveAchievement(userId, unlockedAchievement);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Evaluate if an achievement should be unlocked
|
||||
Future<bool> _evaluateAchievement(
|
||||
AchievementDefinition definition,
|
||||
UserDataModel userData,
|
||||
) async {
|
||||
return await definition.evaluate(userData);
|
||||
}
|
||||
|
||||
/// Calculate progress for an achievement
|
||||
Future<double> _calculateProgress(
|
||||
AchievementDefinition definition,
|
||||
UserDataModel userData,
|
||||
) async {
|
||||
return await definition.calculateProgress?.call(userData) ?? 0.0;
|
||||
}
|
||||
|
||||
/// Get user achievements from database
|
||||
Future<List<AchievementDto>> _getUserAchievements(int userId) async {
|
||||
final user = await _isar.userModels.get(userId);
|
||||
if (user?.userData.value == null) return [];
|
||||
|
||||
return user!.userData.value!.achievements
|
||||
.map((model) => model.toDto())
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Save achievement to user data
|
||||
Future<void> _saveAchievement(int userId, AchievementDto achievement) async {
|
||||
final user = await _isar.userModels.get(userId);
|
||||
if (user?.userData.value == null) return;
|
||||
|
||||
final userData = user!.userData.value!;
|
||||
final model = AchievementModel.fromDto(achievement);
|
||||
|
||||
// Update achievements list
|
||||
final existingAchievements = userData.achievements;
|
||||
final updatedAchievements = [
|
||||
...existingAchievements.where((a) => a.id != achievement.id),
|
||||
model,
|
||||
];
|
||||
|
||||
final updatedUserData =
|
||||
userData.copyWith(achievements: updatedAchievements);
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.userDataModels.put(updatedUserData);
|
||||
});
|
||||
}
|
||||
|
||||
/// Achievement definitions
|
||||
static final List<AchievementDefinition> _achievementDefinitions = [
|
||||
// First Steps
|
||||
AchievementDefinition(
|
||||
id: 'first_word',
|
||||
title: 'First Word',
|
||||
description: 'Learn your first word',
|
||||
type: AchievementType.firstWordLearned,
|
||||
evaluate: (data) => Future.value(data.words.isNotEmpty),
|
||||
supportsProgress: false,
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
id: 'first_test',
|
||||
title: 'First Test',
|
||||
description: 'Complete your first test',
|
||||
type: AchievementType.firstTestCompleted,
|
||||
evaluate: (data) => Future.value(data.testsStatistics.isNotEmpty),
|
||||
supportsProgress: false,
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
id: 'first_pack',
|
||||
title: 'First Pack',
|
||||
description: 'Complete your first pack',
|
||||
type: AchievementType.firstPackCompleted,
|
||||
evaluate: (data) =>
|
||||
Future.value(data.packProgress.any((p) => p.progress >= 1.0)),
|
||||
supportsProgress: false,
|
||||
),
|
||||
|
||||
// Streak Achievements
|
||||
AchievementDefinition(
|
||||
id: 'streak_3',
|
||||
title: '3-Day Streak',
|
||||
description: 'Study for 3 consecutive days',
|
||||
type: AchievementType.streak3Days,
|
||||
evaluate: (data) => Future.value(data.currentStreak >= 3),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.currentStreak / 3.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
id: 'streak_7',
|
||||
title: 'Week Warrior',
|
||||
description: 'Study for 7 consecutive days',
|
||||
type: AchievementType.streak7Days,
|
||||
evaluate: (data) => Future.value(data.currentStreak >= 7),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.currentStreak / 7.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
id: 'streak_30',
|
||||
title: 'Monthly Master',
|
||||
description: 'Study for 30 consecutive days',
|
||||
type: AchievementType.streak30Days,
|
||||
evaluate: (data) => Future.value(data.currentStreak >= 30),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.currentStreak / 30.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
id: 'streak_100',
|
||||
title: 'Century Champion',
|
||||
description: 'Study for 100 consecutive days',
|
||||
type: AchievementType.streak100Days,
|
||||
evaluate: (data) => Future.value(data.currentStreak >= 100),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.currentStreak / 100.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
// Words Mastery
|
||||
AchievementDefinition(
|
||||
id: 'words_10',
|
||||
title: 'Word Explorer',
|
||||
description: 'Learn 10 words',
|
||||
type: AchievementType.words10Learned,
|
||||
evaluate: (data) => Future.value(data.words.length >= 10),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.words.length / 10.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
id: 'words_50',
|
||||
title: 'Vocabulary Builder',
|
||||
description: 'Learn 50 words',
|
||||
type: AchievementType.words50Learned,
|
||||
evaluate: (data) => Future.value(data.words.length >= 50),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.words.length / 50.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
id: 'words_100',
|
||||
title: 'Language Learner',
|
||||
description: 'Learn 100 words',
|
||||
type: AchievementType.words100Learned,
|
||||
evaluate: (data) => Future.value(data.words.length >= 100),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.words.length / 100.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
id: 'words_500',
|
||||
title: 'Word Master',
|
||||
description: 'Learn 500 words',
|
||||
type: AchievementType.words500Learned,
|
||||
evaluate: (data) => Future.value(data.words.length >= 500),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.words.length / 500.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
id: 'words_1000',
|
||||
title: 'Vocabulary Expert',
|
||||
description: 'Learn 1000 words',
|
||||
type: AchievementType.words1000Learned,
|
||||
evaluate: (data) => Future.value(data.words.length >= 1000),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.words.length / 1000.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
// Performance Achievements
|
||||
AchievementDefinition(
|
||||
id: 'perfect_test',
|
||||
title: 'Perfect Score',
|
||||
description: 'Complete a test with 100% accuracy',
|
||||
type: AchievementType.perfectTestScore,
|
||||
evaluate: (data) => Future.value(
|
||||
data.testsStatistics.any((test) => test.attempts.any((attempt) {
|
||||
final totalWords = attempt.words.length;
|
||||
final correctWords = attempt.words
|
||||
.where((word) => word.correct > word.incorrect)
|
||||
.length;
|
||||
return totalWords > 0 && correctWords == totalWords;
|
||||
})),
|
||||
),
|
||||
supportsProgress: false,
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
id: 'speed_learner',
|
||||
title: 'Speed Learner',
|
||||
description: 'Complete a pack in less than 24 hours',
|
||||
type: AchievementType.speedLearner,
|
||||
evaluate: (data) => Future.value(
|
||||
data.packProgress.any((pack) =>
|
||||
pack.progress >= 1.0 &&
|
||||
pack.studyTimeMinutes < 24 * 60 // Less than 24 hours
|
||||
),
|
||||
),
|
||||
supportsProgress: false,
|
||||
),
|
||||
|
||||
// Dedication Achievements
|
||||
AchievementDefinition(
|
||||
id: 'dedicated_learner',
|
||||
title: 'Dedicated Learner',
|
||||
description: 'Study for 100 hours total',
|
||||
type: AchievementType.dedicatedLearner,
|
||||
evaluate: (data) => Future.value(data.totalStudyTimeMinutes >= 100 * 60),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) => Future.value(
|
||||
(data.totalStudyTimeMinutes / (100 * 60)).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
// Time-based Achievements
|
||||
AchievementDefinition(
|
||||
id: 'early_bird',
|
||||
title: 'Early Bird',
|
||||
description: 'Study before 6 AM',
|
||||
type: AchievementType.earlyBird,
|
||||
evaluate: (data) => Future.value(
|
||||
data.studyDates.any((date) => date.hour < 6),
|
||||
),
|
||||
supportsProgress: false,
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
id: 'night_owl',
|
||||
title: 'Night Owl',
|
||||
description: 'Study after 10 PM',
|
||||
type: AchievementType.nightOwl,
|
||||
evaluate: (data) => Future.value(
|
||||
data.studyDates.any((date) => date.hour >= 22),
|
||||
),
|
||||
supportsProgress: false,
|
||||
),
|
||||
|
||||
// Special Achievements
|
||||
AchievementDefinition(
|
||||
id: 'consistent_learner',
|
||||
title: 'Consistent Learner',
|
||||
description: 'Study every day for a month',
|
||||
type: AchievementType.consistentLearner,
|
||||
evaluate: (data) => Future.value(data.longestStreak >= 30),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) =>
|
||||
Future.value((data.longestStreak / 30.0).clamp(0.0, 1.0)),
|
||||
),
|
||||
|
||||
AchievementDefinition(
|
||||
id: 'language_master',
|
||||
title: 'Language Master',
|
||||
description: 'Complete 5 packs',
|
||||
type: AchievementType.languageMaster,
|
||||
evaluate: (data) => Future.value(
|
||||
data.packProgress.where((p) => p.progress >= 1.0).length >= 5,
|
||||
),
|
||||
supportsProgress: true,
|
||||
calculateProgress: (data) => Future.value(
|
||||
(data.packProgress.where((p) => p.progress >= 1.0).length / 5.0)
|
||||
.clamp(0.0, 1.0),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Definition of an achievement with evaluation logic
|
||||
class AchievementDefinition {
|
||||
final String id;
|
||||
final String title;
|
||||
final String description;
|
||||
final AchievementType type;
|
||||
final Future<bool> Function(UserDataModel data) evaluate;
|
||||
final bool supportsProgress;
|
||||
final Future<double> Function(UserDataModel data)? calculateProgress;
|
||||
|
||||
AchievementDefinition({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.type,
|
||||
required this.evaluate,
|
||||
this.supportsProgress = false,
|
||||
this.calculateProgress,
|
||||
});
|
||||
|
||||
/// Create an unlocked achievement DTO
|
||||
AchievementDto unlock() {
|
||||
return AchievementDto(
|
||||
id: id,
|
||||
title: title,
|
||||
description: description,
|
||||
type: type,
|
||||
final companion = UserAchievementsCompanion.insert(
|
||||
userId: userId,
|
||||
achievementId: achievementId,
|
||||
unlockedAt: DateTime.now(),
|
||||
progress: 1.0,
|
||||
);
|
||||
|
||||
try {
|
||||
await _db.achievementDao.unlockAchievement(companion);
|
||||
return definition.unlock();
|
||||
} catch (e) {
|
||||
// Achievement might already exist, return existing
|
||||
final existing = await _db.achievementDao.getUserAchievement(userId, achievementId);
|
||||
return existing != null ? definition.unlock() : null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a locked achievement DTO with progress
|
||||
AchievementDto withProgress(double progress) {
|
||||
return AchievementDto(
|
||||
id: id,
|
||||
title: title,
|
||||
description: description,
|
||||
type: type,
|
||||
progress: progress.clamp(0.0, 1.0),
|
||||
);
|
||||
/// Get achievement progress for user
|
||||
Future<Map<String, double>> getAchievementProgress(int userId) async {
|
||||
return await _db.achievementDao.getAchievementProgress(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if achievement condition is met
|
||||
Future<bool> _checkAchievementCondition(
|
||||
int userId,
|
||||
UserDataModel userData,
|
||||
AchievementDto achievement,
|
||||
) async {
|
||||
switch (achievement.type) {
|
||||
case AchievementType.firstWordLearned:
|
||||
return userData.totalCards > 0;
|
||||
|
||||
case AchievementType.firstTestCompleted:
|
||||
return userData.totalTests > 0;
|
||||
|
||||
case AchievementType.firstPackCompleted:
|
||||
final userPacks = await _db.userDao.getUserPacks(userId);
|
||||
return userPacks.isNotEmpty;
|
||||
|
||||
case AchievementType.words10Learned:
|
||||
return userData.totalCards >= 10;
|
||||
|
||||
case AchievementType.words50Learned:
|
||||
return userData.totalCards >= 50;
|
||||
|
||||
case AchievementType.words100Learned:
|
||||
return userData.totalCards >= 100;
|
||||
|
||||
case AchievementType.words500Learned:
|
||||
return userData.totalCards >= 500;
|
||||
|
||||
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)
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate progress towards achievement
|
||||
Future<double> _calculateAchievementProgress(
|
||||
int userId,
|
||||
UserDataModel userData,
|
||||
AchievementDto achievement,
|
||||
) async {
|
||||
switch (achievement.type) {
|
||||
case AchievementType.words10Learned:
|
||||
return (userData.totalCards / 10.0).clamp(0.0, 1.0);
|
||||
|
||||
case AchievementType.words50Learned:
|
||||
return (userData.totalCards / 50.0).clamp(0.0, 1.0);
|
||||
|
||||
case AchievementType.words100Learned:
|
||||
return (userData.totalCards / 100.0).clamp(0.0, 1.0);
|
||||
|
||||
case AchievementType.words500Learned:
|
||||
return (userData.totalCards / 500.0).clamp(0.0, 1.0);
|
||||
|
||||
case AchievementType.words1000Learned:
|
||||
return (userData.totalCards / 1000.0).clamp(0.0, 1.0);
|
||||
|
||||
default:
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,14 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
|
||||
/// Service for tracking and managing user study sessions
|
||||
///
|
||||
/// Automatically creates, updates, and manages study sessions based on user activity.
|
||||
/// Sessions are created when users start studying and are ended based on inactivity timeouts.
|
||||
@lazySingleton
|
||||
class SessionTracker {
|
||||
final Isar _isar;
|
||||
final AppDatabase _db;
|
||||
|
||||
/// Active sessions cache: userId -> sessionId
|
||||
final Map<int, String> _activeSessions = {};
|
||||
|
|
@ -22,7 +19,7 @@ class SessionTracker {
|
|||
/// Session timeout duration (30 minutes by default)
|
||||
static const Duration _sessionTimeout = Duration(minutes: 30);
|
||||
|
||||
SessionTracker(this._isar);
|
||||
SessionTracker(this._db);
|
||||
|
||||
/// Get or create an active session for a user
|
||||
///
|
||||
|
|
@ -48,237 +45,148 @@ class SessionTracker {
|
|||
|
||||
// Create new session
|
||||
final sessionId = _generateSessionId();
|
||||
final session = StudySessionModel(
|
||||
sessionId: sessionId,
|
||||
userId: userId,
|
||||
startTime: DateTime.now(),
|
||||
packId: packId,
|
||||
testId: testId,
|
||||
final now = DateTime.now();
|
||||
|
||||
await _db.statisticsDao.createSession(
|
||||
StudySessionsCompanion.insert(
|
||||
userId: userId,
|
||||
sessionId: drift.Value(sessionId),
|
||||
startTime: now,
|
||||
packId: drift.Value(packId),
|
||||
testId: drift.Value(testId),
|
||||
),
|
||||
);
|
||||
|
||||
// Save to database
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.studySessionModels.put(session);
|
||||
});
|
||||
|
||||
// Track as active
|
||||
// Cache active session
|
||||
_activeSessions[userId] = sessionId;
|
||||
_resetSessionTimer(sessionId);
|
||||
|
||||
// Start timeout timer
|
||||
_startSessionTimer(sessionId);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/// Update session with progress data
|
||||
///
|
||||
/// Called when user completes a test or makes progress.
|
||||
Future<void> updateSessionProgress(
|
||||
String sessionId, {
|
||||
int wordsLearned = 0,
|
||||
int testsCompleted = 0,
|
||||
double? accuracy,
|
||||
}) async {
|
||||
final session = await _isar.studySessionModels
|
||||
.filter()
|
||||
.sessionIdEqualTo(sessionId)
|
||||
.findFirst();
|
||||
|
||||
if (session == null || !session.isActive) return;
|
||||
|
||||
// Update session with new progress
|
||||
final updatedSession = session.addProgress(
|
||||
wordsLearned: wordsLearned,
|
||||
testsCompleted: testsCompleted,
|
||||
accuracy: accuracy,
|
||||
);
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.studySessionModels.put(updatedSession);
|
||||
});
|
||||
|
||||
// Reset timeout timer
|
||||
_resetSessionTimer(sessionId);
|
||||
}
|
||||
|
||||
/// End a session manually with final statistics
|
||||
///
|
||||
/// Called when user explicitly ends a session or when session times out.
|
||||
/// End a session manually
|
||||
Future<void> endSession(
|
||||
String sessionId, {
|
||||
int wordsLearned = 0,
|
||||
int testsCompleted = 0,
|
||||
double accuracy = 0.0,
|
||||
int? wordsLearned,
|
||||
int? testsCompleted,
|
||||
double? accuracy,
|
||||
}) async {
|
||||
final session = await _isar.studySessionModels
|
||||
.filter()
|
||||
.sessionIdEqualTo(sessionId)
|
||||
.findFirst();
|
||||
// Cancel timer
|
||||
_sessionTimers[sessionId]?.cancel();
|
||||
_sessionTimers.remove(sessionId);
|
||||
|
||||
if (session == null || !session.isActive) return;
|
||||
// Find user for this session
|
||||
final userId = _activeSessions.entries
|
||||
.where((entry) => entry.value == sessionId)
|
||||
.map((entry) => entry.key)
|
||||
.firstOrNull;
|
||||
|
||||
// End session with final statistics
|
||||
final endedSession = session.end(
|
||||
if (userId != null) {
|
||||
_activeSessions.remove(userId);
|
||||
}
|
||||
|
||||
// Update session in database
|
||||
await _db.statisticsDao.endSession(
|
||||
sessionId: sessionId,
|
||||
wordsLearned: wordsLearned,
|
||||
testsCompleted: testsCompleted,
|
||||
accuracy: accuracy,
|
||||
);
|
||||
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.studySessionModels.put(endedSession);
|
||||
});
|
||||
|
||||
// Clean up tracking
|
||||
final userId = session.userId;
|
||||
_activeSessions.remove(userId);
|
||||
_cancelSessionTimer(sessionId);
|
||||
}
|
||||
|
||||
/// End session for a specific user
|
||||
Future<void> endUserSession(int userId) async {
|
||||
final sessionId = _activeSessions[userId];
|
||||
if (sessionId != null) {
|
||||
/// Update session statistics
|
||||
Future<void> updateSessionStats(
|
||||
String sessionId, {
|
||||
int? wordsLearned,
|
||||
int? testsCompleted,
|
||||
double? accuracy,
|
||||
}) async {
|
||||
// Find session by sessionId
|
||||
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
|
||||
if (session == null) return;
|
||||
|
||||
// Update session
|
||||
final updatedSession = session.copyWith(
|
||||
wordsLearned: wordsLearned ?? session.wordsLearned,
|
||||
testsCompleted: testsCompleted ?? session.testsCompleted,
|
||||
accuracy: accuracy ?? session.accuracy,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await _db.statisticsDao.updateSession(updatedSession);
|
||||
}
|
||||
|
||||
/// Get active session for user
|
||||
Future<StudySession?> getActiveSession(int userId) async {
|
||||
return await _db.statisticsDao.getActiveSession(userId);
|
||||
}
|
||||
|
||||
/// Get session history for user
|
||||
Future<List<StudySession>> getUserSessions(
|
||||
int userId, {
|
||||
int? limit,
|
||||
DateTime? fromDate,
|
||||
DateTime? toDate,
|
||||
}) async {
|
||||
return await _db.statisticsDao.getSessionsByUserId(
|
||||
userId,
|
||||
limit: limit,
|
||||
fromDate: fromDate,
|
||||
toDate: toDate,
|
||||
);
|
||||
}
|
||||
|
||||
/// Clean up expired sessions (called by cron job)
|
||||
Future<void> cleanupExpiredSessions() async {
|
||||
// End all active sessions that have timed out
|
||||
final now = DateTime.now();
|
||||
final expiredSessions = <String>[];
|
||||
|
||||
for (final entry in _activeSessions.entries) {
|
||||
final sessionId = entry.value;
|
||||
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
|
||||
|
||||
if (session != null &&
|
||||
session.endTime == null &&
|
||||
now.difference(session.startTime).inMinutes > _sessionTimeout.inMinutes) {
|
||||
expiredSessions.add(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
for (final sessionId in expiredSessions) {
|
||||
await endSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get active session for a user
|
||||
Future<StudySessionModel?> getActiveSession(int userId) async {
|
||||
final sessionId = _activeSessions[userId];
|
||||
if (sessionId == null) return null;
|
||||
|
||||
return await _isar.studySessionModels
|
||||
.filter()
|
||||
.sessionIdEqualTo(sessionId)
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
/// Get recent sessions for a user
|
||||
Future<List<StudySessionModel>> getRecentSessions(
|
||||
int userId, {
|
||||
int limit = 10,
|
||||
}) async {
|
||||
return await _isar.studySessionModels
|
||||
.filter()
|
||||
.userIdEqualTo(userId)
|
||||
.sortByStartTimeDesc()
|
||||
.limit(limit)
|
||||
.findAll();
|
||||
}
|
||||
|
||||
/// Clean up expired sessions
|
||||
///
|
||||
/// Should be called periodically (e.g., via cron job)
|
||||
Future<void> cleanupExpiredSessions() async {
|
||||
final cutoffTime = DateTime.now().subtract(_sessionTimeout);
|
||||
|
||||
// Find sessions that should have expired but are still marked as active
|
||||
final expiredSessions = await _isar.studySessionModels
|
||||
.filter()
|
||||
.endTimeIsNull()
|
||||
.startTimeLessThan(cutoffTime)
|
||||
.findAll();
|
||||
|
||||
for (final session in expiredSessions) {
|
||||
await endSession(session.sessionId!);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get session statistics for a user over a time period
|
||||
Future<Map<String, dynamic>> getSessionStatistics(
|
||||
int userId, {
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
}) async {
|
||||
final query = _isar.studySessionModels.filter().userIdEqualTo(userId);
|
||||
|
||||
if (from != null) {
|
||||
query.startTimeGreaterThan(from);
|
||||
}
|
||||
if (to != null) {
|
||||
query.startTimeLessThan(to);
|
||||
}
|
||||
|
||||
final sessions = await query.findAll();
|
||||
|
||||
if (sessions.isEmpty) {
|
||||
return {
|
||||
'totalSessions': 0,
|
||||
'totalDurationMinutes': 0,
|
||||
'totalWordsLearned': 0,
|
||||
'totalTestsCompleted': 0,
|
||||
'averageAccuracy': 0.0,
|
||||
'averageSessionLength': 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
final completedSessions = sessions.where((s) => !s.isActive).toList();
|
||||
final totalDuration = completedSessions.fold<int>(
|
||||
0,
|
||||
(sum, session) => sum + session.durationMinutes,
|
||||
);
|
||||
final totalWordsLearned = completedSessions.fold<int>(
|
||||
0,
|
||||
(sum, session) => sum + session.wordsLearned,
|
||||
);
|
||||
final totalTestsCompleted = completedSessions.fold<int>(
|
||||
0,
|
||||
(sum, session) => sum + session.testsCompleted,
|
||||
);
|
||||
final averageAccuracy = completedSessions.isEmpty
|
||||
? 0.0
|
||||
: completedSessions.fold<double>(
|
||||
0,
|
||||
(sum, session) => sum + session.accuracy,
|
||||
) /
|
||||
completedSessions.length;
|
||||
|
||||
return {
|
||||
'totalSessions': sessions.length,
|
||||
'completedSessions': completedSessions.length,
|
||||
'totalDurationMinutes': totalDuration,
|
||||
'totalWordsLearned': totalWordsLearned,
|
||||
'totalTestsCompleted': totalTestsCompleted,
|
||||
'averageAccuracy': averageAccuracy,
|
||||
'averageSessionLength': completedSessions.isEmpty
|
||||
? 0.0
|
||||
: totalDuration / completedSessions.length,
|
||||
};
|
||||
}
|
||||
|
||||
/// Generate a unique session ID
|
||||
String _generateSessionId() {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final random = DateTime.now().microsecondsSinceEpoch % 10000;
|
||||
return 'session_${timestamp}_$random';
|
||||
final random = Random();
|
||||
final bytes = List<int>.generate(16, (i) => random.nextInt(256));
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
/// Reset the timeout timer for a session
|
||||
void _resetSessionTimer(String sessionId) {
|
||||
_cancelSessionTimer(sessionId);
|
||||
|
||||
_sessionTimers[sessionId] = Timer(_sessionTimeout, () {
|
||||
// Session timed out - end it
|
||||
endSession(sessionId);
|
||||
/// Start timeout timer for session
|
||||
void _startSessionTimer(String sessionId) {
|
||||
_sessionTimers[sessionId] = Timer(_sessionTimeout, () async {
|
||||
await endSession(sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
/// Cancel the timeout timer for a session
|
||||
void _cancelSessionTimer(String sessionId) {
|
||||
final timer = _sessionTimers.remove(sessionId);
|
||||
timer?.cancel();
|
||||
/// Reset timeout timer for session
|
||||
void _resetSessionTimer(String sessionId) {
|
||||
_sessionTimers[sessionId]?.cancel();
|
||||
_startSessionTimer(sessionId);
|
||||
}
|
||||
|
||||
/// Update session last activity time
|
||||
/// Update session activity timestamp
|
||||
Future<void> _updateSessionActivity(String sessionId) async {
|
||||
// For now, we just reset the timer
|
||||
// In the future, we could track last activity timestamps
|
||||
}
|
||||
|
||||
/// Dispose of all timers (for cleanup)
|
||||
void dispose() {
|
||||
for (final timer in _sessionTimers.values) {
|
||||
timer.cancel();
|
||||
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
|
||||
if (session != null) {
|
||||
await _db.statisticsDao.updateSession(
|
||||
session.copyWith(updatedAt: DateTime.now()),
|
||||
);
|
||||
}
|
||||
_sessionTimers.clear();
|
||||
_activeSessions.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
106
mnemo_cards_backend/lib/tasks/task_manager.dart
Normal file
106
mnemo_cards_backend/lib/tasks/task_manager.dart
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import 'package:injectable/injectable.dart';
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
|
||||
@lazySingleton
|
||||
class TaskManager {
|
||||
final AppDatabase _db;
|
||||
|
||||
TaskManager(this._db);
|
||||
|
||||
/// Получить все доступные задачи пользователя
|
||||
Future<List<UserTask>> getUserTasks(
|
||||
int userId, {
|
||||
String? type,
|
||||
String? difficulty,
|
||||
String? status,
|
||||
List<String>? tags,
|
||||
int? limit,
|
||||
int? offset,
|
||||
}) async {
|
||||
// TODO: Implement with proper filtering
|
||||
return await _db.taskDao.getUserTasks(userId);
|
||||
}
|
||||
|
||||
/// Получить задачу по ID
|
||||
Future<UserTask?> getUserTask(int userId, int taskId) async {
|
||||
final tasks = await _db.taskDao.getUserTasks(userId);
|
||||
return tasks.where((task) => task.id == taskId).firstOrNull;
|
||||
}
|
||||
|
||||
/// Начать выполнение задачи
|
||||
Future<void> startTask(int userId, int taskId) async {
|
||||
await _db.transaction(() async {
|
||||
// Проверить, что задача доступна
|
||||
final task = await getUserTask(userId, taskId);
|
||||
if (task == null || task.status != 'available') {
|
||||
throw Exception('Task not available');
|
||||
}
|
||||
|
||||
// Обновить статус задачи
|
||||
await _db.taskDao.updateUserTask(task.copyWith(status: 'in_progress'));
|
||||
|
||||
// Создать запись прогресса
|
||||
await _db.taskDao.createTaskProgress(
|
||||
UserTaskProgressesCompanion.insert(
|
||||
userId: userId,
|
||||
taskId: taskId,
|
||||
progress: {'started': true},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Завершить задачу
|
||||
Future<void> completeTask(int userId, int taskId) async {
|
||||
await _db.transaction(() async {
|
||||
// Проверить, что задача в процессе выполнения
|
||||
final task = await getUserTask(userId, taskId);
|
||||
if (task == null || task.status != 'in_progress') {
|
||||
throw Exception('Task not in progress');
|
||||
}
|
||||
|
||||
// Обновить статус задачи
|
||||
final now = DateTime.now();
|
||||
await _db.taskDao.updateUserTask(
|
||||
task.copyWith(
|
||||
status: 'completed',
|
||||
completedAt: drift.Value(now),
|
||||
),
|
||||
);
|
||||
|
||||
// Создать запись результата
|
||||
await _db.taskDao.createTaskResult(
|
||||
UserTaskResultsCompanion.insert(
|
||||
userId: userId,
|
||||
taskId: taskId,
|
||||
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<String>> getTaskCategories() async {
|
||||
// TODO: Extract unique categories from tasks
|
||||
return ['app_internal', 'external', 'social'];
|
||||
}
|
||||
|
||||
/// Создать новую задачу для пользователя
|
||||
Future<int> createUserTask(UserTasksCompanion task) async {
|
||||
return await _db.taskDao.createUserTask(task);
|
||||
}
|
||||
|
||||
/// Обновить задачу пользователя
|
||||
Future<bool> updateUserTask(UserTask task) async {
|
||||
return await _db.taskDao.updateUserTask(task);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,14 +2,14 @@ import 'dart:convert';
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:mnemo_cards_backend/database/database.dart';
|
||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||
import 'package:mnemo_cards_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 '../main.dart';
|
||||
import '../packs/pack_dto_converter.dart';
|
||||
import 'generators/question_generators/input_buttons_question_generator.dart';
|
||||
import 'generators/pack_test_generator.dart';
|
||||
|
|
@ -17,214 +17,228 @@ import 'generators/question_generators/simple_question_generator.dart';
|
|||
|
||||
@lazySingleton
|
||||
class TestManager {
|
||||
final AppDatabase _db;
|
||||
final PackDtoConverter _packDtoConverter;
|
||||
List<CreationTestData> _customCreationTestData = [];
|
||||
|
||||
TestManager(this._packDtoConverter);
|
||||
TestManager(this._db, this._packDtoConverter);
|
||||
|
||||
Future<TestStatisticsDto?> _testStatisticsDto(
|
||||
UserModel user, int testId) async {
|
||||
return (await user.userData.value?.testsStatistics
|
||||
.filter()
|
||||
.test((q) => q.idEqualTo(testId))
|
||||
.findFirst())
|
||||
?.toDto();
|
||||
int userId, int testId) async {
|
||||
final statistics = await _db.testDao.getTestStatistics(userId, testId);
|
||||
if (statistics == null) return null;
|
||||
|
||||
// Convert TestStatistic to TestStatisticsDto
|
||||
return TestStatisticsDto(
|
||||
testId: statistics.testId.toString(),
|
||||
results: statistics.results,
|
||||
completedAt: statistics.completedAt.toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<TestDto?> fetchTest(String id, UserModel user) async {
|
||||
return (await isar.testModels.get(int.parse(id)))?.toDto(
|
||||
statistics: await _testStatisticsDto(user, int.parse(id)),
|
||||
final testId = int.tryParse(id);
|
||||
if (testId == null) return null;
|
||||
|
||||
final test = await _db.testDao.getTestById(testId);
|
||||
if (test == null) return null;
|
||||
|
||||
final questions = await _db.testDao.getTestQuestions(testId);
|
||||
final statistics = await _testStatisticsDto(user.id!, testId);
|
||||
|
||||
// Convert Test to TestDto
|
||||
final questionsList = questions.map((q) {
|
||||
// Parse question body based on type
|
||||
final body = json.decode(q.body) as Map<String, dynamic>;
|
||||
return AbstractTestQuestion.fromJson({
|
||||
'questionType': q.questionType,
|
||||
...body,
|
||||
});
|
||||
}).toList();
|
||||
|
||||
return TestDto(
|
||||
id: testId.toString(),
|
||||
name: test.name,
|
||||
color: test.color,
|
||||
cover: test.cover,
|
||||
version: test.version ?? '1.0',
|
||||
time: test.time,
|
||||
timeSubtitle: test.timeSubtitle,
|
||||
questions: questionsList,
|
||||
statistics: statistics,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<TestDto>> availableTests(UserModel userModel) async {
|
||||
final tests = (await isar.testModels.where().findAll());
|
||||
final dtos = await Future.wait(tests.map(
|
||||
(test) async => test.toDto(
|
||||
questions: [],
|
||||
statistics: await _testStatisticsDto(userModel, test.id!),
|
||||
),
|
||||
));
|
||||
return dtos.toList();
|
||||
final tests = await _db.testDao.getAllTests();
|
||||
|
||||
final testDtos = <TestDto>[];
|
||||
for (final test in tests) {
|
||||
final questions = await _db.testDao.getTestQuestions(test.id);
|
||||
final statistics = await _testStatisticsDto(userModel.id!, test.id);
|
||||
|
||||
final questionsList = questions.map((q) {
|
||||
final body = json.decode(q.body) as Map<String, dynamic>;
|
||||
return AbstractTestQuestion.fromJson({
|
||||
'questionType': q.questionType,
|
||||
...body,
|
||||
});
|
||||
}).toList();
|
||||
|
||||
testDtos.add(TestDto(
|
||||
id: test.id.toString(),
|
||||
name: test.name,
|
||||
color: test.color,
|
||||
cover: test.cover,
|
||||
version: test.version ?? '1.0',
|
||||
time: test.time,
|
||||
timeSubtitle: test.timeSubtitle,
|
||||
questions: questionsList,
|
||||
statistics: statistics,
|
||||
));
|
||||
}
|
||||
|
||||
return testDtos;
|
||||
}
|
||||
|
||||
Future<List<TestDto>> fetchPackTests(
|
||||
UserModel user,
|
||||
CardPackModel model,
|
||||
) async {
|
||||
final pack = (await isar.cardPackModels.get(model.id!))!;
|
||||
final Iterable<TestModel> testModels;
|
||||
if (await _updateGeneratedTestsIfRequired(pack)) {
|
||||
testModels = (await isar.cardPackModels.get(model.id!))!.tests;
|
||||
} else {
|
||||
testModels = pack.tests;
|
||||
final packId = model.id;
|
||||
if (packId == null) return [];
|
||||
|
||||
final tests = await _db.testDao.getTestsByPackId(packId);
|
||||
|
||||
final testDtos = <TestDto>[];
|
||||
for (final test in tests) {
|
||||
final questions = await _db.testDao.getTestQuestions(test.id);
|
||||
final statistics = await _testStatisticsDto(user.id!, test.id);
|
||||
|
||||
final questionsList = questions.map((q) {
|
||||
final body = json.decode(q.body) as Map<String, dynamic>;
|
||||
return AbstractTestQuestion.fromJson({
|
||||
'questionType': q.questionType,
|
||||
...body,
|
||||
});
|
||||
}).toList();
|
||||
|
||||
testDtos.add(TestDto(
|
||||
id: test.id.toString(),
|
||||
name: test.name,
|
||||
color: test.color,
|
||||
cover: test.cover,
|
||||
version: test.version ?? '1.0',
|
||||
time: test.time,
|
||||
timeSubtitle: test.timeSubtitle,
|
||||
questions: questionsList,
|
||||
statistics: statistics,
|
||||
));
|
||||
}
|
||||
return (await Future.wait(
|
||||
testModels.map(
|
||||
(e) async => e.toDto(
|
||||
statistics: await _testStatisticsDto(user, e.id!),
|
||||
),
|
||||
),
|
||||
))
|
||||
.toList();
|
||||
|
||||
return testDtos;
|
||||
}
|
||||
|
||||
Future<bool> _updateGeneratedTestsIfRequired(CardPackModel model) async {
|
||||
final generated = model.tests.where((t) => t.version == 'generated');
|
||||
if (generated.isEmpty) {
|
||||
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 {
|
||||
final dir = Directory('${PackManager.assetsDirectory.path}/tests/');
|
||||
List<CreationTestData> data = [];
|
||||
if (await dir.exists()) {
|
||||
final files = dir.listSync().whereType<File>().toList();
|
||||
await Future.wait(
|
||||
files.map(
|
||||
(file) => file
|
||||
.readAsString()
|
||||
.then((s) => data.add(
|
||||
s.decode(CreationTestData.fromJson),
|
||||
))
|
||||
.catchError(
|
||||
(err) {
|
||||
print('Error when loading custom test data: ${file.path}');
|
||||
print(err);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
_customCreationTestData = data;
|
||||
// Load custom creation test data from database
|
||||
// For now, keep it simple
|
||||
_customCreationTestData = [];
|
||||
}
|
||||
|
||||
Future<void> updateGeneratedTests(CardPackModel model) async {
|
||||
final oldGenerated =
|
||||
model.tests.where((t) => t.version == 'generated').toList();
|
||||
final cardPackDto = _packDtoConverter.toDto(model);
|
||||
final packCreationTestData = CreationTestData.fromCardPackDto(cardPackDto);
|
||||
final customCreationTestData = _customCreationTestData.where(
|
||||
(data) => data.packId == cardPackDto.id,
|
||||
);
|
||||
final generator = PackTestGenerator(packCreationTestData);
|
||||
final testDtos = [
|
||||
await generator.generate(
|
||||
name: 'Мини тест',
|
||||
multiply: 1.0,
|
||||
ratios: {
|
||||
TestQuestionType.simple: 1.0,
|
||||
TestQuestionType.input_buttons: 0.1,
|
||||
},
|
||||
),
|
||||
for (final customData in customCreationTestData)
|
||||
await PackTestGenerator(
|
||||
customData,
|
||||
color: customData.color ?? cardPackDto.color,
|
||||
generators: {
|
||||
TestQuestionType.simple: SimpleQuestionGenerator(
|
||||
customData,
|
||||
possibleTypes: {
|
||||
SimpleQuestionType.original_translation,
|
||||
SimpleQuestionType.translation_original,
|
||||
SimpleQuestionType.audio_translation,
|
||||
},
|
||||
),
|
||||
},
|
||||
).generate(
|
||||
name: customData.title,
|
||||
multiply: 1.0,
|
||||
ratios: {
|
||||
TestQuestionType.simple: 1.0,
|
||||
},
|
||||
),
|
||||
await PackTestGenerator(
|
||||
packCreationTestData,
|
||||
generators: {
|
||||
TestQuestionType.simple: SimpleQuestionGenerator.images(
|
||||
packCreationTestData,
|
||||
),
|
||||
TestQuestionType.input_buttons: InputButtonsQuestionGenerator.images(
|
||||
packCreationTestData,
|
||||
),
|
||||
},
|
||||
).generate(
|
||||
name: 'Тест с картинками',
|
||||
multiply: 2.0,
|
||||
ratios: {
|
||||
TestQuestionType.simple: 1.0,
|
||||
TestQuestionType.input_buttons: 0.25,
|
||||
},
|
||||
),
|
||||
await generator.generate(
|
||||
name: 'Тест на написание',
|
||||
multiply: 2.0,
|
||||
ratios: {
|
||||
TestQuestionType.input_buttons: 1.0,
|
||||
},
|
||||
),
|
||||
await generator.generate(
|
||||
name: 'Большой тест',
|
||||
multiply: 1.5,
|
||||
ratios: {
|
||||
TestQuestionType.simple: 1.0,
|
||||
TestQuestionType.input_buttons: 1.0,
|
||||
},
|
||||
),
|
||||
];
|
||||
final packId = model.id;
|
||||
if (packId == null) return;
|
||||
|
||||
isar.writeTxn(() async {
|
||||
for (final test in testDtos) {
|
||||
final testModel = test.toEmptyModel();
|
||||
final questionModels = test.questions
|
||||
.map(
|
||||
(e) => TestQuestionModel(
|
||||
questionType: e.questionType,
|
||||
body: jsonEncode(e.toJson()),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
await isar.testQuestionModels.putAll(questionModels);
|
||||
testModel.questions.addAll(questionModels);
|
||||
testModel.packs.add(model);
|
||||
await isar.testModels.put(testModel);
|
||||
await testModel.packs.save();
|
||||
await testModel.questions.save();
|
||||
}
|
||||
if (oldGenerated.isNotEmpty) {
|
||||
for (final test in oldGenerated) {
|
||||
await test.questions.load();
|
||||
final questions =
|
||||
test.questions.map((e) => e.id).whereNotNull().toList();
|
||||
if (questions.isNotEmpty) {
|
||||
await isar.testQuestionModels.deleteAll(questions);
|
||||
}
|
||||
await isar.testModels.delete(test.id!);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Get pack data from database
|
||||
final pack = await _db.packDao.getPackById(packId);
|
||||
if (pack == null) return;
|
||||
|
||||
// Get all cards for the pack
|
||||
final cards = await _db.packDao.getPackCards(packId);
|
||||
if (cards.isEmpty) return;
|
||||
|
||||
// Create creation test data
|
||||
final testDataItems = cards.map((card) {
|
||||
return TestDataItem(
|
||||
id: card.id.toString(),
|
||||
original: card.original,
|
||||
translation: card.translation,
|
||||
mnemo: card.mnemo,
|
||||
image: card.image,
|
||||
back: card.back,
|
||||
transcription: card.transcription,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
final creationTestData = CreationTestData(
|
||||
items: testDataItems,
|
||||
color: pack.color,
|
||||
packId: packId.toString(),
|
||||
);
|
||||
|
||||
// Generate test using PackTestGenerator
|
||||
final generator = PackTestGenerator(creationTestData);
|
||||
final testDto = await generator.generate(
|
||||
name: '${pack.title} Test',
|
||||
ratios: {
|
||||
TestQuestionType.simple: 0.7,
|
||||
TestQuestionType.input_buttons: 0.3,
|
||||
},
|
||||
multiply: 1.0,
|
||||
);
|
||||
|
||||
// Save test to database
|
||||
await addTest(testDto);
|
||||
|
||||
// Link test to pack
|
||||
final testId = int.tryParse(testDto.id ?? '');
|
||||
if (testId != null) {
|
||||
await _db.testDao.linkTestToPack(testId, packId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addTest(TestDto testDto) async {
|
||||
await isar.writeTxn(() async {
|
||||
final questions = <TestQuestionModel>[];
|
||||
for (final q in testDto.questions.where((q) => q.body != null)) {
|
||||
final model = TestQuestionModel(
|
||||
id: q.id,
|
||||
questionType: q.questionType,
|
||||
body: q.body!,
|
||||
await _db.transaction(() async {
|
||||
// Create test
|
||||
final testCompanion = TestsCompanion.insert(
|
||||
name: testDto.name,
|
||||
color: drift.Value(testDto.color),
|
||||
cover: drift.Value(testDto.cover),
|
||||
version: drift.Value(testDto.version),
|
||||
time: drift.Value(testDto.time),
|
||||
timeSubtitle: drift.Value(testDto.timeSubtitle),
|
||||
);
|
||||
|
||||
final testId = await _db.testDao.createTest(testCompanion);
|
||||
|
||||
// Create questions
|
||||
for (final question in testDto.questions) {
|
||||
final questionCompanion = TestQuestionsCompanion.insert(
|
||||
testId: testId,
|
||||
questionType: question.questionType.name,
|
||||
body: json.encode(question.toJson()),
|
||||
);
|
||||
await isar.testQuestionModels.put(model);
|
||||
questions.add(model);
|
||||
|
||||
await _db.testDao.createTestQuestion(questionCompanion);
|
||||
}
|
||||
final testModel = testDto.toEmptyModel();
|
||||
await isar.testModels.put(testModel);
|
||||
await testModel.questions
|
||||
..addAll(questions)
|
||||
..save();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
211
mnemo_cards_backend/lib/tests/test_manager.dart.backup
Normal file
211
mnemo_cards_backend/lib/tests/test_manager.dart.backup
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.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 '../main.dart';
|
||||
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';
|
||||
|
||||
// TODO: This is a temporary stub implementation while migrating to Drift
|
||||
@lazySingleton
|
||||
class TestManager {
|
||||
final PackDtoConverter _packDtoConverter;
|
||||
List<CreationTestData> _customCreationTestData = [];
|
||||
|
||||
TestManager(this._packDtoConverter);
|
||||
|
||||
Future<TestStatisticsDto?> _testStatisticsDto(
|
||||
UserModel user, int testId) async {
|
||||
return (await user.userData.value?.testsStatistics
|
||||
.filter()
|
||||
.test((q) => q.idEqualTo(testId))
|
||||
.findFirst())
|
||||
?.toDto();
|
||||
}
|
||||
|
||||
Future<TestDto?> fetchTest(String id, UserModel user) async {
|
||||
// TODO: Implement with Drift
|
||||
throw UnimplementedError('TestManager not yet migrated to Drift');
|
||||
}
|
||||
|
||||
Future<List<TestDto>> availableTests(UserModel userModel) async {
|
||||
// TODO: Implement with Drift
|
||||
return []; // Return empty list for now
|
||||
}
|
||||
|
||||
Future<List<TestDto>> fetchPackTests(
|
||||
UserModel user,
|
||||
CardPackModel model,
|
||||
) async {
|
||||
// TODO: Implement with Drift
|
||||
return []; // Return empty list for now
|
||||
}
|
||||
|
||||
Future<bool> _updateGeneratedTestsIfRequired(CardPackModel model) async {
|
||||
final generated = model.tests.where((t) => t.version == 'generated');
|
||||
if (generated.isEmpty) {
|
||||
await updateGeneratedTests(model);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> refreshCustomCreationTestsData() async {
|
||||
final dir = Directory('${PackManager.assetsDirectory.path}/tests/');
|
||||
List<CreationTestData> data = [];
|
||||
if (await dir.exists()) {
|
||||
final files = dir.listSync().whereType<File>().toList();
|
||||
await Future.wait(
|
||||
files.map(
|
||||
(file) => file
|
||||
.readAsString()
|
||||
.then((s) => data.add(
|
||||
s.decode(CreationTestData.fromJson),
|
||||
))
|
||||
.catchError(
|
||||
(err) {
|
||||
print('Error when loading custom test data: ${file.path}');
|
||||
print(err);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
_customCreationTestData = data;
|
||||
}
|
||||
|
||||
Future<void> updateGeneratedTests(CardPackModel model) async {
|
||||
final oldGenerated =
|
||||
model.tests.where((t) => t.version == 'generated').toList();
|
||||
final cardPackDto = _packDtoConverter.toDto(model);
|
||||
final packCreationTestData = CreationTestData.fromCardPackDto(cardPackDto);
|
||||
final customCreationTestData = _customCreationTestData.where(
|
||||
(data) => data.packId == cardPackDto.id,
|
||||
);
|
||||
final generator = PackTestGenerator(packCreationTestData);
|
||||
final testDtos = [
|
||||
await generator.generate(
|
||||
name: 'Мини тест',
|
||||
multiply: 1.0,
|
||||
ratios: {
|
||||
TestQuestionType.simple: 1.0,
|
||||
TestQuestionType.input_buttons: 0.1,
|
||||
},
|
||||
),
|
||||
for (final customData in customCreationTestData)
|
||||
await PackTestGenerator(
|
||||
customData,
|
||||
color: customData.color ?? cardPackDto.color,
|
||||
generators: {
|
||||
TestQuestionType.simple: SimpleQuestionGenerator(
|
||||
customData,
|
||||
possibleTypes: {
|
||||
SimpleQuestionType.original_translation,
|
||||
SimpleQuestionType.translation_original,
|
||||
SimpleQuestionType.audio_translation,
|
||||
},
|
||||
),
|
||||
},
|
||||
).generate(
|
||||
name: customData.title,
|
||||
multiply: 1.0,
|
||||
ratios: {
|
||||
TestQuestionType.simple: 1.0,
|
||||
},
|
||||
),
|
||||
await PackTestGenerator(
|
||||
packCreationTestData,
|
||||
generators: {
|
||||
TestQuestionType.simple: SimpleQuestionGenerator.images(
|
||||
packCreationTestData,
|
||||
),
|
||||
TestQuestionType.input_buttons: InputButtonsQuestionGenerator.images(
|
||||
packCreationTestData,
|
||||
),
|
||||
},
|
||||
).generate(
|
||||
name: 'Тест с картинками',
|
||||
multiply: 2.0,
|
||||
ratios: {
|
||||
TestQuestionType.simple: 1.0,
|
||||
TestQuestionType.input_buttons: 0.25,
|
||||
},
|
||||
),
|
||||
await generator.generate(
|
||||
name: 'Тест на написание',
|
||||
multiply: 2.0,
|
||||
ratios: {
|
||||
TestQuestionType.input_buttons: 1.0,
|
||||
},
|
||||
),
|
||||
await generator.generate(
|
||||
name: 'Большой тест',
|
||||
multiply: 1.5,
|
||||
ratios: {
|
||||
TestQuestionType.simple: 1.0,
|
||||
TestQuestionType.input_buttons: 1.0,
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
isar.writeTxn(() async {
|
||||
for (final test in testDtos) {
|
||||
final testModel = test.toEmptyModel();
|
||||
final questionModels = test.questions
|
||||
.map(
|
||||
(e) => TestQuestionModel(
|
||||
questionType: e.questionType,
|
||||
body: jsonEncode(e.toJson()),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
await isar.testQuestionModels.putAll(questionModels);
|
||||
testModel.questions.addAll(questionModels);
|
||||
testModel.packs.add(model);
|
||||
await isar.testModels.put(testModel);
|
||||
await testModel.packs.save();
|
||||
await testModel.questions.save();
|
||||
}
|
||||
if (oldGenerated.isNotEmpty) {
|
||||
for (final test in oldGenerated) {
|
||||
await test.questions.load();
|
||||
final questions =
|
||||
test.questions.map((e) => e.id).whereNotNull().toList();
|
||||
if (questions.isNotEmpty) {
|
||||
await isar.testQuestionModels.deleteAll(questions);
|
||||
}
|
||||
await isar.testModels.delete(test.id!);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> addTest(TestDto testDto) async {
|
||||
await isar.writeTxn(() async {
|
||||
final questions = <TestQuestionModel>[];
|
||||
for (final q in testDto.questions.where((q) => q.body != null)) {
|
||||
final model = TestQuestionModel(
|
||||
id: q.id,
|
||||
questionType: q.questionType,
|
||||
body: q.body!,
|
||||
);
|
||||
await isar.testQuestionModels.put(model);
|
||||
questions.add(model);
|
||||
}
|
||||
final testModel = testDto.toEmptyModel();
|
||||
await isar.testModels.put(testModel);
|
||||
await testModel.questions
|
||||
..addAll(questions)
|
||||
..save();
|
||||
});
|
||||
}
|
||||
}
|
||||
40
mnemo_cards_backend/lib/user/user_drift_extension.dart
Normal file
40
mnemo_cards_backend/lib/user/user_drift_extension.dart
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import 'package:isar/isar.dart';
|
||||
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';
|
||||
|
||||
/// 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
|
||||
);
|
||||
|
||||
// TODO: Загрузить связанные данные (userData, packs, subscription)
|
||||
// Пока оставляем пустыми для совместимости
|
||||
|
||||
return userModel;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension для конвертации UserModel в User (Drift)
|
||||
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
|
||||
name: drift.Value(name),
|
||||
email: drift.Value(email),
|
||||
admin: drift.Value(admin),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
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';
|
||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||
|
||||
|
|
@ -11,77 +12,76 @@ import '../statistics/session_tracker.dart';
|
|||
import '../statistics/statistics_calculator.dart';
|
||||
import '../statistics/achievement_manager.dart';
|
||||
import 'secure.dart';
|
||||
import 'user_drift_extension.dart';
|
||||
|
||||
Map<Id?, DateTime> _onlineUsers = {};
|
||||
Map<int?, DateTime> _onlineUsers = {};
|
||||
|
||||
@lazySingleton
|
||||
class UserManager {
|
||||
final AppDatabase _db;
|
||||
final FreePacksDistributor _freePacksDistributor;
|
||||
final SessionTracker _sessionTracker;
|
||||
final StatisticsCalculator _statisticsCalculator;
|
||||
final AchievementManager _achievementManager;
|
||||
// TODO: Re-enable when migrated to PostgreSQL
|
||||
// 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(Id id) async {
|
||||
final user = await isar.userModels.get(id);
|
||||
return user;
|
||||
Future<UserModel?> fetchUser(int id) async {
|
||||
final user = await _db.userDao.getUserById(id);
|
||||
if (user == null) return null;
|
||||
return await user.toUserModel();
|
||||
}
|
||||
|
||||
DateTime? lastOnline(Id id) => _onlineUsers[id];
|
||||
DateTime? lastOnline(int id) => _onlineUsers[id];
|
||||
|
||||
Future<String> createOrGetAuthToken(UserModel user, String externalId) async {
|
||||
if (user.id == null) {
|
||||
throw Exception('Cant create token for empty id');
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final tokenModel =
|
||||
await isar.tokenModels.filter().userIdEqualTo(user.id!).findFirst();
|
||||
if (tokenModel != null) {
|
||||
if (tokenModel.expires.isAfter(now)) {
|
||||
return tokenModel.token;
|
||||
final token = await _db.userDao.getTokenByUserId(user.id!);
|
||||
if (token != null) {
|
||||
if (token.expires.isAfter(now)) {
|
||||
return token.token;
|
||||
}
|
||||
await isar.tokenModels.delete(tokenModel.id!);
|
||||
await _db.userDao.deleteToken(token.id);
|
||||
}
|
||||
final userToken = Secure.token();
|
||||
await isar.tokenModels.put(
|
||||
TokenModel(
|
||||
await _db.userDao.createToken(
|
||||
TokensCompanion.insert(
|
||||
token: userToken,
|
||||
externalUserId: externalId,
|
||||
userId: user.id!,
|
||||
created: now,
|
||||
expires: now.add(Duration(days: 360)),
|
||||
expires: now.add(const Duration(days: 360)),
|
||||
),
|
||||
);
|
||||
return userToken;
|
||||
}
|
||||
|
||||
Future<UserModel?> getUserByToken(String authToken) async {
|
||||
final tokenModel =
|
||||
await isar.tokenModels.filter().tokenEqualTo(authToken).findFirst();
|
||||
if (tokenModel == null) {
|
||||
final token = await _db.userDao.getTokenByValue(authToken);
|
||||
if (token == null) {
|
||||
return null;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
if (tokenModel.expires.isBefore(now)) {
|
||||
isar.writeTxn(() => isar.tokenModels.delete(tokenModel.id!));
|
||||
if (token.expires.isBefore(now)) {
|
||||
await _db.userDao.deleteToken(token.id);
|
||||
return null;
|
||||
}
|
||||
final user = await isar.userModels.get(tokenModel.userId);
|
||||
if (user == null) {
|
||||
return null;
|
||||
} else {
|
||||
_onlineUsers[user.id] = now;
|
||||
final user = await fetchUser(token.userId);
|
||||
if (user != null) {
|
||||
_onlineUsers[token.userId] = now;
|
||||
if (_onlineUsers.length > 300) {
|
||||
updateOnlineUsers();
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
|
|
@ -89,28 +89,23 @@ class UserManager {
|
|||
if (_onlineUsers.isEmpty) {
|
||||
return;
|
||||
}
|
||||
return isar.writeTxn(() async {
|
||||
final ids = _onlineUsers.keys.whereNotNull().toList();
|
||||
final users = (await isar.userModels.getAll(ids)).whereNotNull();
|
||||
await Future.wait(users.map((user) => user.userData.load()));
|
||||
final updated = users
|
||||
.map((user) => user.userData.value?.copyWith(
|
||||
lastTimeOnline: _onlineUsers[user.id] ??
|
||||
user.userData.value?.lastTimeOnline,
|
||||
))
|
||||
.whereNotNull()
|
||||
.toList();
|
||||
await isar.userDataModels.putAll(updated);
|
||||
_onlineUsers.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<TokenModel>> getTokensByUser(String userId) async {
|
||||
final id = int.tryParse(userId);
|
||||
if (id == null) return [];
|
||||
return isar.txn(
|
||||
() => isar.tokenModels.filter().userIdEqualTo(id).findAll(),
|
||||
);
|
||||
final userIds = _onlineUsers.keys.whereNotNull().toList();
|
||||
|
||||
for (final userId in userIds) {
|
||||
final lastOnline = _onlineUsers[userId];
|
||||
if (lastOnline != null) {
|
||||
await _db.userDao.updateUserDataPartial(
|
||||
UserDatasCompanion(
|
||||
userId: drift.Value(userId),
|
||||
lastTimeOnline: drift.Value(lastOnline),
|
||||
updatedAt: drift.Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_onlineUsers.clear();
|
||||
}
|
||||
|
||||
Future<(UserModel, String)> createOrGetUser({
|
||||
|
|
@ -118,369 +113,49 @@ class UserManager {
|
|||
required String email,
|
||||
String? name,
|
||||
}) async {
|
||||
final tokenModel = await isar.tokenModels
|
||||
.filter()
|
||||
.externalUserIdEqualTo(externalId)
|
||||
.findFirst();
|
||||
var user = tokenModel == null ? null : await fetchUser(tokenModel.userId);
|
||||
if (user == null) {
|
||||
print('Creating new user $name $email');
|
||||
final modelAndToken = await isar.writeTxn(() async {
|
||||
final userModel = UserModel.empty.copyWith(
|
||||
email: email,
|
||||
name: name,
|
||||
);
|
||||
|
||||
await isar.userModels.put(userModel);
|
||||
print('User $name $email saved');
|
||||
await isar.tokenModels
|
||||
.filter()
|
||||
.externalUserIdEqualTo(externalId)
|
||||
.deleteFirst();
|
||||
print('User tokens deleted $name $email');
|
||||
final token = await createOrGetAuthToken(userModel, externalId);
|
||||
final userData = UserDataModel()..user.value = userModel;
|
||||
await isar.userDataModels.put(userData);
|
||||
userModel.userData.value = userData;
|
||||
await userModel.userData.save();
|
||||
return (userModel, token);
|
||||
});
|
||||
await _freePacksDistributor.giveFreePacksToUser(modelAndToken.$1);
|
||||
return modelAndToken;
|
||||
} else {
|
||||
if (user.email == null) {
|
||||
user = user.copyWith.email(email);
|
||||
await isar.writeTxn(() => isar.userModels.put(user!));
|
||||
}
|
||||
await isar.writeTxn(() async {
|
||||
await user!.userData.load();
|
||||
if (user.userData.value == null) {
|
||||
final userData = UserDataModel()..user.value = user;
|
||||
await isar.userDataModels.put(userData);
|
||||
user.userData.value = userData;
|
||||
await user.userData.save();
|
||||
}
|
||||
});
|
||||
return (user, tokenModel!.token);
|
||||
// Check if user exists by externalId
|
||||
final existingUser = await _db.userDao.getUserByExternalId(externalId);
|
||||
if (existingUser != null) {
|
||||
print('User found $name $email');
|
||||
final userModel = await existingUser.toUserModel();
|
||||
final token = await createOrGetAuthToken(userModel, externalId);
|
||||
return (userModel, token);
|
||||
}
|
||||
}
|
||||
|
||||
// Future<void> addWordStatistics(
|
||||
// UserModel user, AllWordsStatisticsDto wordStat) async {
|
||||
// // final currentData =
|
||||
// // user.userData?.decode(UserDataDto.fromJson) ?? UserDataDto();
|
||||
// // updateUserData(
|
||||
// // user,
|
||||
// // currentData.copyWith.allWordsStatistics(
|
||||
// // wordStat.merge(currentData.allWordsStatistics),
|
||||
// // ),
|
||||
// // );
|
||||
// }
|
||||
print('Creating new user $name $email');
|
||||
|
||||
Future<void> addTestStatistics(
|
||||
UserModel user,
|
||||
TestStatisticsDto testStat,
|
||||
) async {
|
||||
UserDataModel currentData;
|
||||
print('adding test stats');
|
||||
if (user.userData.value == null) {
|
||||
print('new user data');
|
||||
currentData = UserDataModel();
|
||||
await isar.writeTxn(() async {
|
||||
final id = await isar.userDataModels.put(
|
||||
currentData..user.value = user,
|
||||
);
|
||||
currentData = (await isar.userDataModels.get(id))!;
|
||||
});
|
||||
print('user data saved');
|
||||
} else {
|
||||
print('found user data');
|
||||
currentData = user.userData.value!;
|
||||
}
|
||||
if (testStat.sessionToken == currentData.lastTestSessionToken) {
|
||||
print('old session token');
|
||||
return;
|
||||
}
|
||||
final testStatistics = await isar.txn(
|
||||
() async =>
|
||||
await currentData.testsStatistics
|
||||
.filter()
|
||||
.test((q) => q.idEqualTo(testStat.testId))
|
||||
.findFirst() ??
|
||||
TestStatisticsModel(),
|
||||
// Create new user
|
||||
final userCompanion = UsersCompanion.insert(
|
||||
externalUserId: externalId,
|
||||
name: drift.Value(name),
|
||||
email: drift.Value(email),
|
||||
);
|
||||
|
||||
print('got tests stat for ${testStat.testId}');
|
||||
final userId = await _db.userDao.createUser(userCompanion);
|
||||
|
||||
final allWords = currentData.words.mergeWithDto(testStat.words.words);
|
||||
print(
|
||||
'merged all words: ${allWords.length} (was ${currentData.words.length})');
|
||||
|
||||
return isar.writeTxn(() async {
|
||||
try {
|
||||
final test = await isar.testModels.get(testStat.testId);
|
||||
// final testWords = updateWords(testStatistics.words, testStat.words.words);
|
||||
final updatedTestStat = testStatistics.copyWith.attempts(
|
||||
[
|
||||
...testStatistics.attempts.takeLast(2),
|
||||
TestAttempt(
|
||||
words: testStat.words.words.map((e) => e.toModel()).toList(),
|
||||
sessionToken: testStat.sessionToken,
|
||||
),
|
||||
],
|
||||
)..test.value = test;
|
||||
|
||||
await isar.testStatisticsModels.put(updatedTestStat);
|
||||
await updatedTestStat.test.save();
|
||||
|
||||
print('updated test stats saved');
|
||||
|
||||
// Calculate updated statistics
|
||||
final studyDates = List<DateTime>.from(currentData.studyDates);
|
||||
final today = DateTime.now();
|
||||
final todayNormalized = DateTime(today.year, today.month, today.day);
|
||||
|
||||
// Add today's study date if not already present
|
||||
if (!studyDates.any((date) =>
|
||||
date.year == todayNormalized.year &&
|
||||
date.month == todayNormalized.month &&
|
||||
date.day == todayNormalized.day)) {
|
||||
studyDates.add(todayNormalized);
|
||||
}
|
||||
|
||||
// Calculate current streak based on study dates
|
||||
final currentStreak = _statisticsCalculator.calculateStreak(studyDates);
|
||||
|
||||
// Calculate longest streak
|
||||
final longestStreak = currentStreak > currentData.longestStreak
|
||||
? currentStreak
|
||||
: currentData.longestStreak;
|
||||
|
||||
// Calculate total study time (simplified - add time from this test)
|
||||
final testDurationMinutes = 5; // Assume 5 minutes per test as default
|
||||
final totalStudyTimeMinutes =
|
||||
currentData.totalStudyTimeMinutes + testDurationMinutes;
|
||||
|
||||
// Calculate pack progress if we have pack info
|
||||
final packProgress =
|
||||
List<PackProgressDto>.from(currentData.packProgress);
|
||||
|
||||
// Update pack progress if test was for a specific pack
|
||||
if (testStat.testId != null) {
|
||||
// This would need pack information lookup - simplified for now
|
||||
// In a real implementation, we'd update the specific pack's progress
|
||||
}
|
||||
|
||||
// Check and unlock achievements with updated data
|
||||
final tempUpdatedData = currentData.copyWith(
|
||||
currentStreak: currentStreak,
|
||||
longestStreak: longestStreak,
|
||||
studyDates: studyDates,
|
||||
totalStudyTimeMinutes: totalStudyTimeMinutes,
|
||||
words: allWords,
|
||||
);
|
||||
|
||||
// Check for newly unlocked achievements
|
||||
await checkAndUpdateAchievements(user, tempUpdatedData);
|
||||
|
||||
final updatedData = currentData.copyWith(
|
||||
lastTestSessionToken: testStat.sessionToken,
|
||||
words: allWords,
|
||||
currentStreak: currentStreak,
|
||||
longestStreak: longestStreak,
|
||||
studyDates: studyDates,
|
||||
totalStudyTimeMinutes: totalStudyTimeMinutes,
|
||||
lastTimeOnline: today,
|
||||
)
|
||||
..testsStatistics.add(updatedTestStat)
|
||||
..user.value = user;
|
||||
|
||||
// Note: packProgress and achievements are handled separately
|
||||
// since UserDataModel expects Model types, not DTO types
|
||||
|
||||
print('saving updated user data');
|
||||
await isar.userDataModels.put(updatedData);
|
||||
await updatedData.testsStatistics.save();
|
||||
await updatedData.user.save();
|
||||
print('user data saved');
|
||||
|
||||
// Track session activity
|
||||
await _updateSessionFromTest(user, testStat, updatedData);
|
||||
} catch (e, s) {
|
||||
print('error while saving data ${e.toString()} ${s.toString()}');
|
||||
log('error', error: e, stackTrace: s);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Check and update achievements for a user
|
||||
Future<List<AchievementDto>> checkAndUpdateAchievements(
|
||||
UserModel user,
|
||||
UserDataModel userData,
|
||||
) async {
|
||||
try {
|
||||
final newlyUnlockedAchievements =
|
||||
await _achievementManager.checkAndUnlockAchievements(
|
||||
user.id!,
|
||||
userData,
|
||||
);
|
||||
|
||||
if (newlyUnlockedAchievements.isNotEmpty) {
|
||||
print(
|
||||
'New achievements unlocked for user ${user.id}: ${newlyUnlockedAchievements.map((a) => a.title).join(', ')}');
|
||||
}
|
||||
|
||||
return newlyUnlockedAchievements;
|
||||
} catch (e, s) {
|
||||
print('Error checking achievements for user ${user.id}: $e\n$s');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Update session tracking based on test completion
|
||||
Future<void> _updateSessionFromTest(
|
||||
UserModel user,
|
||||
TestStatisticsDto testStat,
|
||||
UserDataModel updatedData,
|
||||
) async {
|
||||
if (user.id == null) return;
|
||||
|
||||
try {
|
||||
// Get or create session for this user
|
||||
final sessionId = await _sessionTracker.getOrCreateSession(
|
||||
user.id!,
|
||||
testId: testStat.testId.toString(),
|
||||
);
|
||||
|
||||
// Calculate test statistics
|
||||
final wordsLearned = testStat.words.words.length;
|
||||
final correctAnswers = testStat.words.words.fold<int>(
|
||||
0,
|
||||
(sum, word) => sum + word.correct.toInt(),
|
||||
);
|
||||
final totalAnswers = testStat.words.words.fold<int>(
|
||||
0,
|
||||
(sum, word) => sum + word.correct.toInt() + word.incorrect.toInt(),
|
||||
);
|
||||
final accuracy = totalAnswers > 0 ? correctAnswers / totalAnswers : 0.0;
|
||||
|
||||
// Update session progress
|
||||
await _sessionTracker.updateSessionProgress(
|
||||
sessionId,
|
||||
wordsLearned: wordsLearned,
|
||||
testsCompleted: 1,
|
||||
accuracy: accuracy,
|
||||
);
|
||||
|
||||
print(
|
||||
'Updated session $sessionId: +$wordsLearned words, accuracy: ${accuracy.toStringAsFixed(2)}');
|
||||
} catch (e, s) {
|
||||
print('Error updating session tracking: $e\n$s');
|
||||
// Don't fail the main operation if session tracking fails
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateUserSettings(UserModel user, UserSettingsDto settings) {
|
||||
return isar.writeTxn(
|
||||
() => isar.userModels.put(
|
||||
user.copyWith(userSettings: settings.encode()),
|
||||
// Create user data
|
||||
await _db.userDao.createUserData(
|
||||
UserDatasCompanion.insert(
|
||||
userId: userId,
|
||||
registrationDate: drift.Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
|
||||
final user = await _db.userDao.getUserById(userId);
|
||||
if (user == null) {
|
||||
throw Exception('Failed to create user');
|
||||
}
|
||||
|
||||
final userModel = await user.toUserModel();
|
||||
final token = await createOrGetAuthToken(userModel, externalId);
|
||||
|
||||
// Give free packs to new user
|
||||
await _freePacksDistributor.giveFreePacksToUser(userModel);
|
||||
|
||||
print('User $name $email created successfully');
|
||||
return (userModel, token);
|
||||
}
|
||||
|
||||
Future<bool> editUser(UserDto user) async {
|
||||
if (user.id == null || user.id! < 0) {
|
||||
throw Exception('Cant edit new user');
|
||||
}
|
||||
final existUser = await isar.userModels.get(user.id!);
|
||||
if (existUser == null) {
|
||||
throw Exception('User ${user.id} not found');
|
||||
}
|
||||
final dtoPackIds = user.packs.map((s) => int.tryParse(s)).whereNotNull();
|
||||
final dtoPacks = dtoPackIds.isEmpty
|
||||
? <CardPackModel>[]
|
||||
: (await isar.cardPackModels.getAll(dtoPackIds.toList()))
|
||||
.whereNotNull()
|
||||
.toList();
|
||||
isar.writeTxn(() async {
|
||||
final updatedUser = existUser.copyWith(
|
||||
name: user.name ?? existUser.name,
|
||||
// subscription: user.subscription,
|
||||
// userSettings: user.userSettingsDto?.encode() ?? existUser.userSettings,
|
||||
// userData: user.userDataDto?.encode() ?? existUser.userData,
|
||||
);
|
||||
final id = await isar.userModels.put(updatedUser);
|
||||
final packs = (await isar.userModels.get(id))!.packs;
|
||||
await updatedUser.subscriptionModel.load();
|
||||
final subscriptionModel = updatedUser.subscriptionModel.value;
|
||||
print(
|
||||
'Editing user ${user.id}, subscription = ${user.subscription} and hasModel = ${subscriptionModel != null}',
|
||||
);
|
||||
if (user.subscription == true) {
|
||||
UserSubscriptionModel userSubscriptionModel;
|
||||
if (subscriptionModel == null) {
|
||||
print('Creating new sub model');
|
||||
userSubscriptionModel = UserSubscriptionModel(
|
||||
start: DateTime.now(),
|
||||
finish: DateTime.now().add(Duration(days: 1)),
|
||||
features: [
|
||||
SubscriptionFeatureEnum.packs,
|
||||
SubscriptionFeatureEnum.ads,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
print('Updating sub model');
|
||||
userSubscriptionModel = subscriptionModel.copyWith(
|
||||
start: DateTime.now(),
|
||||
finish: DateTime.now().add(Duration(days: 1)),
|
||||
features: [
|
||||
SubscriptionFeatureEnum.packs,
|
||||
SubscriptionFeatureEnum.ads,
|
||||
]);
|
||||
}
|
||||
updatedUser.subscriptionModel.value = userSubscriptionModel;
|
||||
await isar.userSubscriptionModels.put(userSubscriptionModel);
|
||||
await updatedUser.subscriptionModel.save();
|
||||
} else if (subscriptionModel != null) {
|
||||
final updatedModel = subscriptionModel.copyWith(
|
||||
start: DateTime.now(),
|
||||
finish: DateTime.now(),
|
||||
features: [],
|
||||
)..user.value = updatedUser;
|
||||
await isar.userSubscriptionModels.put(updatedModel);
|
||||
updatedUser.subscriptionModel.value = updatedModel;
|
||||
await updatedUser.subscriptionModel.save();
|
||||
}
|
||||
await packs.reset();
|
||||
await (packs..addAll(dtoPacks)).save();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> deleteUser(String stringId) async {
|
||||
final id = int.tryParse(stringId);
|
||||
if (id != null) {
|
||||
await isar.writeTxn(() async {
|
||||
final tokens = await isar.tokenModels
|
||||
.filter()
|
||||
.userIdEqualTo(id)
|
||||
.idProperty()
|
||||
.findAll();
|
||||
if (tokens.isNotEmpty) {
|
||||
await isar.tokenModels.deleteAll(tokens);
|
||||
}
|
||||
await isar.testStatisticsModels
|
||||
.filter()
|
||||
.userData((q) => q.user((u) => u.idEqualTo(id)))
|
||||
.deleteAll();
|
||||
await isar.userDataModels
|
||||
.filter()
|
||||
.user((q) => q.idEqualTo(id))
|
||||
.deleteAll();
|
||||
return isar.userModels.delete(id);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// TODO: Implement remaining methods as needed
|
||||
// updateUserSettings, addTestStatistics, editUser, deleteUser
|
||||
}
|
||||
160
mnemo_cards_backend/lib/user/user_manager_drift.dart
Normal file
160
mnemo_cards_backend/lib/user/user_manager_drift.dart
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:injectable/injectable.dart';
|
||||
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';
|
||||
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';
|
||||
import '../statistics/achievement_manager.dart';
|
||||
import 'secure.dart';
|
||||
import 'user_drift_extension.dart';
|
||||
|
||||
Map<int?, DateTime> _onlineUsers = {};
|
||||
|
||||
@lazySingleton
|
||||
class UserManager {
|
||||
final AppDatabase _db;
|
||||
final FreePacksDistributor _freePacksDistributor;
|
||||
final SessionTracker _sessionTracker;
|
||||
final StatisticsCalculator _statisticsCalculator;
|
||||
final AchievementManager _achievementManager;
|
||||
|
||||
UserManager(
|
||||
this._db,
|
||||
this._freePacksDistributor,
|
||||
this._sessionTracker,
|
||||
this._statisticsCalculator,
|
||||
this._achievementManager,
|
||||
);
|
||||
|
||||
Future<UserModel?> fetchUser(int id) async {
|
||||
final user = await _db.userDao.getUserById(id);
|
||||
if (user == null) return null;
|
||||
return await user.toUserModel();
|
||||
}
|
||||
|
||||
DateTime? lastOnline(int id) => _onlineUsers[id];
|
||||
|
||||
Future<String> createOrGetAuthToken(UserModel user, String externalId) async {
|
||||
if (user.id == null) {
|
||||
throw Exception('Cant create token for empty id');
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final token = await _db.userDao.getTokenByUserId(user.id!);
|
||||
if (token != null) {
|
||||
if (token.expires.isAfter(now)) {
|
||||
return token.token;
|
||||
}
|
||||
await _db.userDao.deleteToken(token.id);
|
||||
}
|
||||
final userToken = Secure.token();
|
||||
await _db.userDao.createToken(
|
||||
TokensCompanion.insert(
|
||||
token: userToken,
|
||||
externalUserId: externalId,
|
||||
userId: user.id!,
|
||||
expires: now.add(const Duration(days: 360)),
|
||||
),
|
||||
);
|
||||
return userToken;
|
||||
}
|
||||
|
||||
Future<UserModel?> getUserByToken(String authToken) async {
|
||||
final token = await _db.userDao.getTokenByValue(authToken);
|
||||
if (token == null) {
|
||||
return null;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
if (token.expires.isBefore(now)) {
|
||||
await _db.userDao.deleteToken(token.id);
|
||||
return null;
|
||||
}
|
||||
final user = await fetchUser(token.userId);
|
||||
if (user != null) {
|
||||
_onlineUsers[token.userId] = now;
|
||||
if (_onlineUsers.length > 300) {
|
||||
updateOnlineUsers();
|
||||
}
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
Future<void> updateOnlineUsers() async {
|
||||
if (_onlineUsers.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final userIds = _onlineUsers.keys.whereNotNull().toList();
|
||||
|
||||
for (final userId in userIds) {
|
||||
final lastOnline = _onlineUsers[userId];
|
||||
if (lastOnline != null) {
|
||||
await _db.userDao.updateUserDataPartial(
|
||||
UserDatasCompanion(
|
||||
userId: drift.Value(userId),
|
||||
lastTimeOnline: drift.Value(lastOnline),
|
||||
updatedAt: drift.Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_onlineUsers.clear();
|
||||
}
|
||||
|
||||
Future<(UserModel, String)> createOrGetUser({
|
||||
required String externalId,
|
||||
required String email,
|
||||
String? name,
|
||||
}) async {
|
||||
// Check if user exists by externalId
|
||||
final existingUser = await _db.userDao.getUserByExternalId(externalId);
|
||||
if (existingUser != null) {
|
||||
print('User found $name $email');
|
||||
final userModel = await existingUser.toUserModel();
|
||||
final token = await createOrGetAuthToken(userModel, externalId);
|
||||
return (userModel, token);
|
||||
}
|
||||
|
||||
print('Creating new user $name $email');
|
||||
|
||||
// Create new user
|
||||
final userCompanion = UsersCompanion.insert(
|
||||
externalUserId: externalId,
|
||||
name: drift.Value(name),
|
||||
email: drift.Value(email),
|
||||
);
|
||||
|
||||
final userId = await _db.userDao.createUser(userCompanion);
|
||||
|
||||
// Create user data
|
||||
await _db.userDao.createUserData(
|
||||
UserDatasCompanion.insert(
|
||||
userId: userId,
|
||||
registrationDate: drift.Value(DateTime.now()),
|
||||
),
|
||||
);
|
||||
|
||||
final user = await _db.userDao.getUserById(userId);
|
||||
if (user == null) {
|
||||
throw Exception('Failed to create user');
|
||||
}
|
||||
|
||||
final userModel = await user.toUserModel();
|
||||
final token = await createOrGetAuthToken(userModel, externalId);
|
||||
|
||||
// Give free packs to new user
|
||||
await _freePacksDistributor.giveFreePacksToUser(userModel);
|
||||
|
||||
print('User $name $email created successfully');
|
||||
return (userModel, token);
|
||||
}
|
||||
|
||||
// TODO: Implement remaining methods as needed
|
||||
// updateUserSettings, addTestStatistics, editUser, deleteUser
|
||||
}
|
||||
|
|
@ -129,15 +129,31 @@ paths:
|
|||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/ads/product/acquire/<key>:
|
||||
/cards:
|
||||
get:
|
||||
tags:
|
||||
- AdminCardsApiV2
|
||||
summary: getAllCards
|
||||
operationId: getAllCards
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
post:
|
||||
tags:
|
||||
- AdsApiV2
|
||||
summary: "POST /api/v2/ads/product/acquire/{key}"
|
||||
description: Confirms rewarded ad completion and grants product access to the user.
|
||||
operationId: acquireProductForAd
|
||||
- AdminCardsApiV2
|
||||
summary: createCard
|
||||
operationId: createCard
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/cards/<cardId>:
|
||||
get:
|
||||
tags:
|
||||
- AdminCardsApiV2
|
||||
summary: getCard
|
||||
operationId: getCard
|
||||
parameters:
|
||||
- name: key
|
||||
- name: cardId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
|
|
@ -145,42 +161,11 @@ paths:
|
|||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/adsgram/reward:
|
||||
get:
|
||||
tags:
|
||||
- AdsApiV2
|
||||
summary: "GET /api/v2/adsgram/reward?userId={userId}"
|
||||
description: Callback endpoint for Adsgram rewarded ad completion.\nThis endpoint is called by Adsgram when a user completes a rewarded ad.
|
||||
operationId: adsgramRewardCallback
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/admin/cards:
|
||||
get:
|
||||
put:
|
||||
tags:
|
||||
- AdminCardsApiV2
|
||||
summary: getCards
|
||||
description: "GET /api/v2/admin/cards\nGet all cards with pagination and optional search\nQuery params: ?page=1&limit=20&search=term"
|
||||
operationId: getCards
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
post:
|
||||
tags:
|
||||
- AdminCardsApiV2
|
||||
summary: upsertCard
|
||||
description: POST /api/v2/admin/cards\nCreate or update a card
|
||||
operationId: upsertCard
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/admin/cards/<cardId>:
|
||||
get:
|
||||
tags:
|
||||
- AdminCardsApiV2
|
||||
summary: getCard
|
||||
description: "GET /api/v2/admin/cards/:id\nGet a specific card by ID"
|
||||
operationId: getCard
|
||||
summary: updateCard
|
||||
operationId: updateCard
|
||||
parameters:
|
||||
- name: cardId
|
||||
in: path
|
||||
|
|
@ -194,7 +179,6 @@ paths:
|
|||
tags:
|
||||
- AdminCardsApiV2
|
||||
summary: deleteCard
|
||||
description: "DELETE /api/v2/admin/cards/:id\nDelete a card by ID"
|
||||
operationId: deleteCard
|
||||
parameters:
|
||||
- name: cardId
|
||||
|
|
@ -354,275 +338,6 @@ paths:
|
|||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/admin/users:
|
||||
get:
|
||||
tags:
|
||||
- AdminUsersApiV2
|
||||
summary: getUsers
|
||||
description: GET /api/v2/admin/users\nReturns list of users by optional ids.
|
||||
operationId: getUsers
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
post:
|
||||
tags:
|
||||
- AdminUsersApiV2
|
||||
summary: upsertUser
|
||||
description: POST /api/v2/admin/users\nCreates or updates user data (admin editing).
|
||||
operationId: upsertUser
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/admin/users/ids:
|
||||
get:
|
||||
tags:
|
||||
- AdminUsersApiV2
|
||||
summary: getUserIds
|
||||
description: GET /api/v2/admin/users/ids\nReturns comma separated user ids.
|
||||
operationId: getUserIds
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/admin/users/<userId>/purchases:
|
||||
get:
|
||||
tags:
|
||||
- AdminUsersApiV2
|
||||
summary: getUserPurchases
|
||||
description: "GET /api/v2/admin/users/<id>/purchases\nReturns user payments history."
|
||||
operationId: getUserPurchases
|
||||
parameters:
|
||||
- name: userId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/admin/users/<userId>:
|
||||
delete:
|
||||
tags:
|
||||
- AdminUsersApiV2
|
||||
summary: deleteUser
|
||||
description: "DELETE /api/v2/admin/users/<id>\nDeletes user."
|
||||
operationId: deleteUser
|
||||
parameters:
|
||||
- name: userId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/games:
|
||||
get:
|
||||
tags:
|
||||
- GamesApiV2
|
||||
summary: getGames
|
||||
description: GET /api/v2/games\nGet all available games\nReturns list of games with metadata
|
||||
operationId: getGames
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/games/<gameId>/assets:
|
||||
get:
|
||||
tags:
|
||||
- GamesApiV2
|
||||
summary: getGameAssets
|
||||
description: "GET /api/v2/games/{gameId}/assets\nGet game assets\nReturns game assets file (zip) or asset info"
|
||||
operationId: getGameAssets
|
||||
parameters:
|
||||
- name: gameId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/packs:
|
||||
get:
|
||||
tags:
|
||||
- PacksApiV2
|
||||
summary: getPacks
|
||||
description: "GET /api/v2/packs\nGet all pack previews with pagination\nQuery params: ?search=term&language=lang&page=1&limit=20"
|
||||
operationId: getPacks
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/packs/<packId>:
|
||||
get:
|
||||
tags:
|
||||
- PacksApiV2
|
||||
summary: getPack
|
||||
description: "GET /api/v2/packs/{packId}\nGet pack details by ID\nReturns full pack details with purchase status if authenticated"
|
||||
operationId: getPack
|
||||
parameters:
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/packs/<packId>/buy:
|
||||
get:
|
||||
tags:
|
||||
- PacksApiV2
|
||||
summary: getPackBuyPage
|
||||
description: "GET /api/v2/packs/{packId}/buy\nReturns pack purchase details (includes rewarded ads offer when available)\nWorks for both authenticated and unauthenticated users"
|
||||
operationId: getPackBuyPage
|
||||
parameters:
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/packs/<packId>/cards:
|
||||
get:
|
||||
tags:
|
||||
- PacksApiV2
|
||||
summary: getPackCards
|
||||
description: "GET /api/v2/packs/{packId}/cards\nGet all cards in a pack\nSupports pagination via query params: ?page=1&limit=20"
|
||||
operationId: getPackCards
|
||||
parameters:
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/packs/<packId>/cards/<cardId>/image:
|
||||
get:
|
||||
tags:
|
||||
- PacksApiV2
|
||||
summary: getCardImage
|
||||
description: "GET /api/v2/packs/{packId}/cards/{cardId}/image\nGet card image\nReturns PNG image file\n\nImages are accessible for enabled packs even without authentication\nto allow image preview in public pack listings"
|
||||
operationId: getCardImage
|
||||
parameters:
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: cardId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/packs/<packId>/cards/<cardId>/voices:
|
||||
get:
|
||||
tags:
|
||||
- PacksApiV2
|
||||
summary: getCardVoices
|
||||
description: "GET /api/v2/packs/{packId}/cards/{cardId}/voices\nGet card voices metadata\nReturns JSON list of VoiceDto"
|
||||
operationId: getCardVoices
|
||||
parameters:
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: cardId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/voice/<voiceId>:
|
||||
get:
|
||||
tags:
|
||||
- PacksApiV2
|
||||
summary: getVoiceFile
|
||||
description: "GET /api/v2/voice/{voiceId}\nReturns audio/mp3 bytes for the voice file"
|
||||
operationId: getVoiceFile
|
||||
parameters:
|
||||
- name: voiceId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/packs/<packId>/tests:
|
||||
get:
|
||||
tags:
|
||||
- PacksApiV2
|
||||
summary: getPackTests
|
||||
description: "GET /api/v2/packs/{packId}/tests\nGet tests for a pack"
|
||||
operationId: getPackTests
|
||||
parameters:
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/admin/packs:
|
||||
get:
|
||||
tags:
|
||||
- AdminPacksApiV2
|
||||
summary: getPacks
|
||||
description: "GET /api/v2/admin/packs\nGet all packs with pagination and optional search\nQuery params: ?page=1&limit=20&search=term&showDisabled=true"
|
||||
operationId: getPacks
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
post:
|
||||
tags:
|
||||
- AdminPacksApiV2
|
||||
summary: upsertPack
|
||||
description: POST /api/v2/admin/packs\nCreate or update a pack
|
||||
operationId: upsertPack
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/admin/packs/<packId>:
|
||||
get:
|
||||
tags:
|
||||
- AdminPacksApiV2
|
||||
summary: getPack
|
||||
description: "GET /api/v2/admin/packs/:id\nGet pack details by ID for editing"
|
||||
operationId: getPack
|
||||
parameters:
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
delete:
|
||||
tags:
|
||||
- AdminPacksApiV2
|
||||
summary: deletePack
|
||||
description: "DELETE /api/v2/admin/packs/:id\nDelete a pack by ID"
|
||||
operationId: deletePack
|
||||
parameters:
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/admin/auth/request-code:
|
||||
post:
|
||||
tags:
|
||||
|
|
@ -669,64 +384,6 @@ paths:
|
|||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/purchases/packs/<packId>:
|
||||
post:
|
||||
tags:
|
||||
- PurchasesApiV2
|
||||
summary: createPackPurchase
|
||||
description: "POST /api/v2/purchases/packs/{packId}\nCreate purchase intent for a pack\nReturns purchase info including payment URL for YooKassa"
|
||||
operationId: createPackPurchase
|
||||
parameters:
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/purchases/packs/<packId>/status:
|
||||
get:
|
||||
tags:
|
||||
- PurchasesApiV2
|
||||
summary: getPackPurchaseStatus
|
||||
description: "GET /api/v2/purchases/packs/{packId}/status\nCheck if pack is purchased by the authenticated user"
|
||||
operationId: getPackPurchaseStatus
|
||||
parameters:
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/purchases/payments:
|
||||
post:
|
||||
tags:
|
||||
- PurchasesApiV2
|
||||
summary: createPayment
|
||||
description: POST /api/v2/purchases/payments\nCreate a payment\nCurrently supports YooKassa for web payments
|
||||
operationId: createPayment
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/purchases/payments/<paymentId>/verify:
|
||||
get:
|
||||
tags:
|
||||
- PurchasesApiV2
|
||||
summary: verifyPayment
|
||||
description: "GET /api/v2/purchases/payments/{paymentId}/verify\nVerify payment status\nUpdates user purchases on success"
|
||||
operationId: verifyPayment
|
||||
parameters:
|
||||
- name: paymentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/auth/oauth/google:
|
||||
post:
|
||||
tags:
|
||||
|
|
@ -880,6 +537,56 @@ paths:
|
|||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/telegram-bot/random-card:
|
||||
get:
|
||||
tags:
|
||||
- TelegramBotApiV2
|
||||
summary: getRandomCard
|
||||
description: "GET /api/v2/telegram-bot/random-card\nGet a random card from all available cards"
|
||||
operationId: getRandomCard
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/telegram-bot/share/check-limit:
|
||||
post:
|
||||
tags:
|
||||
- TelegramBotApiV2
|
||||
summary: checkShareLimit
|
||||
description: "POST /api/v2/telegram-bot/share/check-limit\nCheck if user can share today (rate limiting)\nBody: { telegramUserId: string, dailyLimit: number }"
|
||||
operationId: checkShareLimit
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/telegram-bot/share/record:
|
||||
post:
|
||||
tags:
|
||||
- TelegramBotApiV2
|
||||
summary: recordShareRequest
|
||||
description: "POST /api/v2/telegram-bot/share/record\nRecord a share request for a user\nBody: { telegramUserId: string, telegramUsername?: string, sharedCardId?: number }"
|
||||
operationId: recordShareRequest
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/telegram-bot/users/info:
|
||||
get:
|
||||
tags:
|
||||
- TelegramBotApiV2
|
||||
summary: getUsersInfo
|
||||
description: "GET /api/v2/telegram-bot/users/info\nGet user information (for admin commands)\nQuery: ?userId=<id> for specific user, or no query for all users summary"
|
||||
operationId: getUsersInfo
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/telegram-bot/words:
|
||||
get:
|
||||
tags:
|
||||
- TelegramBotApiV2
|
||||
summary: getWords
|
||||
description: "GET /api/v2/telegram-bot/words\nGet all words from all cards\nQuery: ?separator=<string> for custom separator (default: comma)"
|
||||
operationId: getWords
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/admin/analytics/dashboard:
|
||||
get:
|
||||
tags:
|
||||
|
|
@ -969,7 +676,7 @@ paths:
|
|||
tags:
|
||||
- TasksApiV2
|
||||
summary: "GET /api/v2/users/me/tasks/progress - Get user task progress"
|
||||
operationId: getUserProgress
|
||||
operationId: getUserTaskProgress
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
|
|
@ -977,94 +684,31 @@ paths:
|
|||
get:
|
||||
tags:
|
||||
- TasksApiV2
|
||||
summary: "GET /api/v2/tasks/categories - Get available task categories and filters"
|
||||
summary: "GET /api/v2/tasks/categories - Get task categories"
|
||||
operationId: getTaskCategories
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/telegram-bot/random-card:
|
||||
get:
|
||||
tags:
|
||||
- TelegramBotApiV2
|
||||
summary: getRandomCard
|
||||
description: "GET /api/v2/telegram-bot/random-card\nGet a random card from all available cards"
|
||||
operationId: getRandomCard
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/telegram-bot/share/check-limit:
|
||||
post:
|
||||
tags:
|
||||
- TelegramBotApiV2
|
||||
summary: checkShareLimit
|
||||
description: "POST /api/v2/telegram-bot/share/check-limit\nCheck if user can share today (rate limiting)\nBody: { telegramUserId: string, dailyLimit: number }"
|
||||
operationId: checkShareLimit
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/telegram-bot/share/record:
|
||||
post:
|
||||
tags:
|
||||
- TelegramBotApiV2
|
||||
summary: recordShareRequest
|
||||
description: "POST /api/v2/telegram-bot/share/record\nRecord a share request for a user\nBody: { telegramUserId: string, telegramUsername?: string, sharedCardId?: number }"
|
||||
operationId: recordShareRequest
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/telegram-bot/users/info:
|
||||
get:
|
||||
tags:
|
||||
- TelegramBotApiV2
|
||||
summary: getUsersInfo
|
||||
description: "GET /api/v2/telegram-bot/users/info\nGet user information (for admin commands)\nQuery: ?userId=<id> for specific user, or no query for all users summary"
|
||||
operationId: getUsersInfo
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
/telegram-bot/words:
|
||||
get:
|
||||
tags:
|
||||
- TelegramBotApiV2
|
||||
summary: getWords
|
||||
description: "GET /api/v2/telegram-bot/words\nGet all words from all cards\nQuery: ?separator=<string> for custom separator (default: comma)"
|
||||
operationId: getWords
|
||||
responses:
|
||||
200:
|
||||
description: "Operation completed!"
|
||||
components: { }
|
||||
tags:
|
||||
- name: PromocodesApiV2
|
||||
description: API v2 endpoints for promocode management and activation.
|
||||
- name: DiscountsApiV2
|
||||
description: Admin endpoints for discount campaign management.
|
||||
- name: AdsApiV2
|
||||
description: API v2 endpoints for rewarded ads flows.
|
||||
- name: AdminCardsApiV2
|
||||
description: Admin endpoints for card management in API v2.
|
||||
- name: SubscriptionsApiV2
|
||||
description: "Subscriptions API v2\nRESTful endpoints for managing subscriptions & plans"
|
||||
- name: UsersApiV2
|
||||
description: "API v2 endpoints for user profile and self-service operations."
|
||||
- name: AdminUsersApiV2
|
||||
description: Admin endpoints for user management in API v2.
|
||||
- name: GamesApiV2
|
||||
description: Games API v2\n\nRESTful endpoints for managing games and game assets
|
||||
- name: PacksApiV2
|
||||
description: API v2 Packs endpoints\n\nRESTful endpoints for card packs with pagination and filtering
|
||||
- name: AdminPacksApiV2
|
||||
description: Admin endpoints for pack management in API v2.
|
||||
- name: AdminAuthApiV2
|
||||
description: Admin authentication API endpoints
|
||||
- name: PurchasesApiV2
|
||||
description: Purchases API v2\n\nRESTful endpoints for managing purchases and payments
|
||||
- name: AuthApiV2
|
||||
description: API v2 Authentication endpoints\n\nImplements OAuth2/JWT Bearer token authentication
|
||||
- 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"
|
||||
- name: AdminAnalyticsApiV2
|
||||
description: Admin endpoints for analytics and statistics in API v2.
|
||||
- name: TasksApiV2
|
||||
description: API v2 endpoints for user tasks management
|
||||
- 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 user tasks management
|
||||
|
|
@ -25,6 +25,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.13.0"
|
||||
analyzer_plugin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer_plugin
|
||||
sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.2"
|
||||
archive:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
|
@ -81,6 +89,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
buffer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: buffer
|
||||
sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
build:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -145,14 +161,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.9.2"
|
||||
characters:
|
||||
charcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
|
||||
name: charcode
|
||||
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
version: "1.4.0"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -169,6 +185,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_util
|
||||
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.2"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -241,14 +265,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
dartx:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dartx
|
||||
sha256: "8b25435617027257d43e6508b5fe061012880ddfdaa75a71d607c3de2a13d244"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -265,6 +281,30 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
drift:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift
|
||||
sha256: b50a8342c6ddf05be53bda1d246404cbad101b64dc73e8d6d1ac1090d119b4e2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.15.0"
|
||||
drift_dev:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: drift_dev
|
||||
sha256: c037d9431b6f8dc633652b1469e5f53aaec6e4eb405ed29dd232fa888ef10d88
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.15.0"
|
||||
drift_postgres:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: drift_postgres
|
||||
sha256: "8c1e123c72aeb3982af53681d4c9327d2a54a980f3bc8658cd891429b0dd3b09"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
encrypt:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -426,21 +466,13 @@ packages:
|
|||
source: hosted
|
||||
version: "1.0.4"
|
||||
isar:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: isar
|
||||
sha256: "99165dadb2cf2329d3140198363a7e7bff9bbd441871898a87e26914d25cf1ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0+1"
|
||||
isar_generator:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: isar_generator
|
||||
sha256: "76c121e1295a30423604f2f819bc255bc79f852f3bc8743a24017df6068ad133"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0+1"
|
||||
jaguar_jwt:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -591,6 +623,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.1"
|
||||
postgres:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: postgres
|
||||
sha256: "83ba7afb0e778cc1f373e514c6110d323b81dfbaced774d3a7f4a0e21536eb45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.8"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -631,6 +671,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
sasl_scram:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sasl_scram
|
||||
sha256: a47207a436eb650f8fdcf54a2e2587b850dc3caef9973ce01f332b07a6fc9cb9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.1"
|
||||
saslprep:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: saslprep
|
||||
sha256: "3d421d10be9513bf4459c17c5e70e7b8bc718c9fc5ad4ba5eb4f5fd27396f740"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
shelf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -767,6 +823,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.0"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
sqlparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlparser
|
||||
sha256: "7b20045d1ccfb7bc1df7e8f9fee5ae58673fce6ff62cefbb0e0fd7214e90e5a0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.34.1"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -831,14 +903,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.5"
|
||||
time:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: time
|
||||
sha256: ad8e018a6c9db36cb917a031853a1aae49467a93e0d464683e029537d848c221
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -855,14 +919,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
unorm_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unorm_dart
|
||||
sha256: "0c69186b03ca6addab0774bcc0f4f17b88d4ce78d9d4d8f0619e30a99ead58e7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.2"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313"
|
||||
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
version: "4.5.2"
|
||||
version:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -927,14 +999,6 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.5.0"
|
||||
xxh3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xxh3
|
||||
sha256: a92b30944a9aeb4e3d4f3c3d4ddb3c7816ca73475cd603682c4f8149690f56d7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -947,9 +1011,9 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: yookassa_client
|
||||
sha256: "667d04d0e2d8c7e5180d26a3966587cc99d38fb887d318e36562a202d1adb6e2"
|
||||
sha256: e801e1bb22f21f883adbee15645e2c9b21c4a640f8e096006a6295c335c588aa
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
version: "1.0.5"
|
||||
sdks:
|
||||
dart: ">=3.4.0 <4.0.0"
|
||||
dart: ">=3.5.0 <4.0.0"
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ description: Mnemo backend
|
|||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
version: 1.0.0+4
|
||||
isar_version: &isar_version 3.1.0+1 # define the version to be used
|
||||
version: 1.0.0+5
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
|
@ -19,12 +18,16 @@ dependencies:
|
|||
mnemo_cards_common_backend:
|
||||
path: ../mnemo_cards_common_backend
|
||||
|
||||
# PostgreSQL + Drift
|
||||
drift: ^2.14.0
|
||||
drift_postgres: ^1.1.0
|
||||
postgres: ^3.0.4
|
||||
|
||||
image: ^4.1.7
|
||||
http: ^1.1.0
|
||||
|
||||
# shared_preferences: ^2.2.2
|
||||
async:
|
||||
isar: *isar_version
|
||||
# path_provider: ^2.1.1
|
||||
get_it: ^7.6.4
|
||||
injectable: ^2.4.1
|
||||
|
|
@ -46,7 +49,7 @@ dependencies:
|
|||
crypto: ^3.0.3
|
||||
googleapis: ^13.1.0
|
||||
googleapis_auth:
|
||||
uuid: ^3.0.7
|
||||
uuid: ^4.5.2
|
||||
|
||||
yookassa_client: ^1.0.2
|
||||
neat_periodic_task: ^2.0.1
|
||||
|
|
@ -54,8 +57,11 @@ dependencies:
|
|||
|
||||
|
||||
dev_dependencies:
|
||||
build_runner:
|
||||
isar_generator: *isar_version
|
||||
build_runner: ^2.4.0
|
||||
|
||||
# Drift code generation
|
||||
drift_dev: ^2.14.0
|
||||
|
||||
shelf_router_generator: ^1.1.0
|
||||
shelf_open_api_generator:
|
||||
injectable_generator:
|
||||
|
|
|
|||
Loading…
Reference in a new issue