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
|
1. Run mnemo_cards_web/complete_auto_debug.sh
|
||||||
2. Wait for it to complete
|
2. Wait for it to complete
|
||||||
3. Read and analyse the report in mnemo_cards_web/debug_report
|
3. Read and analyse the report in mnemo_cards_web/debug_report
|
||||||
4. Fix the errors
|
4. Never leave TODOS
|
||||||
5. Go to step 1
|
5. Fix the errors
|
||||||
|
6. Go to step 1
|
||||||
|
|
@ -4,7 +4,6 @@ alwaysApply: true
|
||||||
Use yx_state and yx_scope,
|
Use yx_state and yx_scope,
|
||||||
Follow clean architecture techique,
|
Follow clean architecture techique,
|
||||||
Modularize and split into files,
|
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:
|
When you finish with another step:
|
||||||
Write unit tests for all the functionalities
|
Write unit tests for all the functionalities
|
||||||
Update PROGESS.md and TODO.md
|
Update PROGESS.md and TODO.md
|
||||||
|
|
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -52,4 +52,15 @@ app.*.map.json
|
||||||
*.isar
|
*.isar
|
||||||
mnemo_cards_backend/backup
|
mnemo_cards_backend/backup
|
||||||
mnemo_cards_backend/build
|
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) {
|
if (_packModelCacheId == packId && _packModelCache != null) {
|
||||||
return _packModelCache;
|
return _packModelCache;
|
||||||
}
|
}
|
||||||
final model = await backend_main.isar.cardPackModels.get(packId);
|
final pack = await packManager.getPack(packId);
|
||||||
_packModelCache = model;
|
// TODO: Convert CardPack to CardPackModel if needed
|
||||||
|
// For now return null to avoid breaking
|
||||||
|
_packModelCache = null; // pack?.toCardPackModel();
|
||||||
_packModelCacheId = packId;
|
_packModelCacheId = packId;
|
||||||
return model;
|
return _packModelCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if pack exists and enabled
|
/// Returns true if pack exists and enabled
|
||||||
|
|
|
||||||
|
|
@ -10,46 +10,43 @@
|
||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
import 'package:get_it/get_it.dart' as _i1;
|
import 'package:get_it/get_it.dart' as _i1;
|
||||||
import 'package:injectable/injectable.dart' as _i2;
|
import 'package:injectable/injectable.dart' as _i2;
|
||||||
import 'package:isar/isar.dart' as _i4;
|
|
||||||
|
|
||||||
import '../../auth/telegram_auth_code_service.dart' as _i20;
|
import '../../auth/telegram_auth_code_service.dart' as _i18;
|
||||||
import '../../cron/check_payment.dart' as _i41;
|
import '../../cron/check_payment.dart' as _i37;
|
||||||
import '../../discounts/discounts_manager.dart' as _i8;
|
import '../../database/database.dart' as _i5;
|
||||||
import '../../packs/free_packs_distributor.dart' as _i9;
|
import '../../discounts/discounts_manager.dart' as _i6;
|
||||||
import '../../packs/pack_dto_converter.dart' as _i27;
|
import '../../packs/free_packs_distributor.dart' as _i7;
|
||||||
import '../../packs/pack_manager.dart' as _i28;
|
import '../../packs/pack_dto_converter.dart' as _i28;
|
||||||
import '../../packs/products_price_resolver.dart' as _i13;
|
import '../../packs/pack_manager.dart' as _i29;
|
||||||
import '../../promo_codes/promo_codes_manager.dart' as _i35;
|
import '../../packs/products_price_resolver.dart' as _i10;
|
||||||
import '../../statistics/achievement_manager.dart' as _i3;
|
import '../../promo_codes/promo_codes_manager.dart' as _i31;
|
||||||
import '../../statistics/session_tracker.dart' as _i15;
|
import '../../statistics/achievement_manager.dart' as _i22;
|
||||||
import '../../statistics/statistics_calculator.dart' as _i16;
|
import '../../statistics/session_tracker.dart' as _i12;
|
||||||
import '../../tests/test_manager.dart' as _i29;
|
import '../../statistics/statistics_calculator.dart' as _i13;
|
||||||
import '../../user/user_manager.dart' as _i22;
|
import '../../tasks/task_manager.dart' as _i16;
|
||||||
import '../ads/ads_manager.dart' as _i7;
|
import '../../tests/test_manager.dart' as _i33;
|
||||||
import '../mnemo_shelf.dart' as _i26;
|
import '../../user/user_manager.dart' as _i20;
|
||||||
import '../purchase/payment_manager.dart' as _i34;
|
import '../../user/user_manager_drift.dart' as _i35;
|
||||||
import '../purchase/rustore/rustore_purchase_handler.dart' as _i14;
|
import '../ads/ads_manager.dart' as _i4;
|
||||||
import '../purchase/yoo_money.dart' as _i31;
|
import '../mnemo_shelf.dart' as _i27;
|
||||||
import '../subscription/subscription_manager.dart' as _i17;
|
import '../purchase/payment_manager.dart' as _i30;
|
||||||
import '../user/google_api.dart' as _i11;
|
import '../purchase/rustore/rustore_purchase_handler.dart' as _i11;
|
||||||
import '../v2/admin_analytics_api_v2.dart' as _i5;
|
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_auth_api_v2.dart' as _i23;
|
||||||
import '../v2/admin_cards_api_v2.dart' as _i6;
|
import '../v2/admin_cards_api_v2.dart' as _i24;
|
||||||
import '../v2/admin_packs_api_v2.dart' as _i32;
|
import '../v2/auth_api_v2.dart' as _i25;
|
||||||
import '../v2/admin_users_api_v2.dart' as _i39;
|
import '../v2/discounts_api_v2.dart' as _i26;
|
||||||
import '../v2/ads_api_v2.dart' as _i40;
|
import '../v2/jwt_service.dart' as _i9;
|
||||||
import '../v2/auth_api_v2.dart' as _i24;
|
import '../v2/promocodes_api_v2.dart' as _i32;
|
||||||
import '../v2/discounts_api_v2.dart' as _i25;
|
import '../v2/subscriptions_api_v2.dart' as _i15;
|
||||||
import '../v2/games_api_v2.dart' as _i10;
|
import '../v2/tasks_api_v2.dart' as _i17;
|
||||||
import '../v2/jwt_service.dart' as _i12;
|
import '../v2/telegram_bot_api_v2.dart' as _i19;
|
||||||
import '../v2/packs_api_v2.dart' as _i33;
|
import '../v2/tests_api_v2.dart' as _i34;
|
||||||
import '../v2/promocodes_api_v2.dart' as _i36;
|
import '../v2/users_api_v2.dart' as _i36;
|
||||||
import '../v2/purchases_api_v2.dart' as _i37;
|
import 'modules.dart' as _i38;
|
||||||
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;
|
|
||||||
|
|
||||||
extension GetItInjectableX on _i1.GetIt {
|
extension GetItInjectableX on _i1.GetIt {
|
||||||
// initializes the registration of main-scope dependencies inside of GetIt
|
// initializes the registration of main-scope dependencies inside of GetIt
|
||||||
|
|
@ -62,106 +59,106 @@ extension GetItInjectableX on _i1.GetIt {
|
||||||
environment,
|
environment,
|
||||||
environmentFilter,
|
environmentFilter,
|
||||||
);
|
);
|
||||||
gh.lazySingleton<_i3.AchievementManager>(
|
final appModule = _$AppModule();
|
||||||
() => _i3.AchievementManager(gh<_i4.Isar>()));
|
gh.lazySingleton<_i3.AdminAnalyticsApiV2>(() => _i3.AdminAnalyticsApiV2());
|
||||||
gh.lazySingleton<_i5.AdminAnalyticsApiV2>(() => _i5.AdminAnalyticsApiV2());
|
gh.lazySingleton<_i4.AdsManager>(() => _i4.AdsManager());
|
||||||
gh.lazySingleton<_i6.AdminCardsApiV2>(() => _i6.AdminCardsApiV2());
|
gh.singleton<_i5.AppDatabase>(() => appModule.database);
|
||||||
gh.lazySingleton<_i7.AdsManager>(() => _i7.AdsManager());
|
gh.lazySingleton<_i6.DiscountsManager>(() => const _i6.DiscountsManager());
|
||||||
gh.lazySingleton<_i8.DiscountsManager>(() => const _i8.DiscountsManager());
|
gh.lazySingleton<_i7.FreePacksDistributor>(
|
||||||
gh.lazySingleton<_i9.FreePacksDistributor>(
|
() => const _i7.FreePacksDistributor());
|
||||||
() => const _i9.FreePacksDistributor());
|
gh.lazySingleton<_i8.GoogleApi>(() => const _i8.GoogleApi());
|
||||||
gh.lazySingleton<_i10.GamesApiV2>(() => _i10.GamesApiV2());
|
gh.lazySingleton<_i9.JwtService>(
|
||||||
gh.lazySingleton<_i11.GoogleApi>(() => const _i11.GoogleApi());
|
() => _i9.JwtService(gh<_i5.AppDatabase>()));
|
||||||
gh.lazySingleton<_i12.JwtService>(() => _i12.JwtService());
|
gh.lazySingleton<_i10.ProductsPriceResolver>(
|
||||||
gh.lazySingleton<_i13.ProductsPriceResolver>(
|
() => _i10.ProductsPriceResolver(gh<_i6.DiscountsManager>()));
|
||||||
() => _i13.ProductsPriceResolver(gh<_i8.DiscountsManager>()));
|
gh.lazySingleton<_i11.RustorePurchaseHandler>(
|
||||||
gh.lazySingleton<_i14.RustorePurchaseHandler>(
|
() => _i11.RustorePurchaseHandler());
|
||||||
() => _i14.RustorePurchaseHandler());
|
gh.lazySingleton<_i12.SessionTracker>(
|
||||||
gh.lazySingleton<_i15.SessionTracker>(
|
() => _i12.SessionTracker(gh<_i5.AppDatabase>()));
|
||||||
() => _i15.SessionTracker(gh<_i4.Isar>()));
|
gh.lazySingleton<_i13.StatisticsCalculator>(
|
||||||
gh.lazySingleton<_i16.StatisticsCalculator>(
|
() => _i13.StatisticsCalculator());
|
||||||
() => _i16.StatisticsCalculator());
|
gh.lazySingleton<_i14.SubscriptionManager>(
|
||||||
gh.lazySingleton<_i17.SubscriptionManager>(
|
() => _i14.SubscriptionManager(gh<_i5.AppDatabase>()));
|
||||||
() => _i17.SubscriptionManager());
|
gh.lazySingleton<_i15.SubscriptionsApiV2>(
|
||||||
gh.lazySingleton<_i18.SubscriptionsApiV2>(
|
() => _i15.SubscriptionsApiV2(gh<_i14.SubscriptionManager>()));
|
||||||
() => _i18.SubscriptionsApiV2(gh<_i17.SubscriptionManager>()));
|
gh.lazySingleton<_i16.TaskManager>(
|
||||||
gh.lazySingleton<_i19.TasksApiV2>(() => const _i19.TasksApiV2());
|
() => _i16.TaskManager(gh<_i5.AppDatabase>()));
|
||||||
gh.lazySingleton<_i20.TelegramAuthCodeService>(
|
gh.lazySingleton<_i17.TasksApiV2>(
|
||||||
() => _i20.TelegramAuthCodeService());
|
() => _i17.TasksApiV2(gh<_i16.TaskManager>()));
|
||||||
gh.lazySingleton<_i21.TelegramBotApiV2>(() => _i21.TelegramBotApiV2());
|
gh.lazySingleton<_i18.TelegramAuthCodeService>(
|
||||||
gh.lazySingleton<_i22.UserManager>(() => _i22.UserManager(
|
() => _i18.TelegramAuthCodeService());
|
||||||
gh<_i9.FreePacksDistributor>(),
|
gh.lazySingleton<_i19.TelegramBotApiV2>(() => _i19.TelegramBotApiV2());
|
||||||
gh<_i15.SessionTracker>(),
|
gh.lazySingleton<_i20.UserManager>(() => _i20.UserManager(
|
||||||
gh<_i16.StatisticsCalculator>(),
|
gh<_i5.AppDatabase>(),
|
||||||
gh<_i3.AchievementManager>(),
|
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.lazySingleton<_i23.AdminAuthApiV2>(() => _i23.AdminAuthApiV2(
|
||||||
gh<_i20.TelegramAuthCodeService>(),
|
gh<_i18.TelegramAuthCodeService>(),
|
||||||
gh<_i22.UserManager>(),
|
gh<_i20.UserManager>(),
|
||||||
gh<_i12.JwtService>(),
|
gh<_i9.JwtService>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i24.AuthApiV2>(() => _i24.AuthApiV2(
|
gh.factory<_i24.AdminCardsApiV2>(
|
||||||
gh<_i22.UserManager>(),
|
() => _i24.AdminCardsApiV2(gh<_i5.AppDatabase>()));
|
||||||
gh<_i11.GoogleApi>(),
|
gh.lazySingleton<_i25.AuthApiV2>(() => _i25.AuthApiV2(
|
||||||
gh<_i12.JwtService>(),
|
gh<_i5.AppDatabase>(),
|
||||||
gh<_i20.TelegramAuthCodeService>(),
|
gh<_i20.UserManager>(),
|
||||||
|
gh<_i8.GoogleApi>(),
|
||||||
|
gh<_i9.JwtService>(),
|
||||||
|
gh<_i18.TelegramAuthCodeService>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i25.DiscountsApiV2>(
|
gh.lazySingleton<_i26.DiscountsApiV2>(
|
||||||
() => _i25.DiscountsApiV2(gh<_i8.DiscountsManager>()));
|
() => _i26.DiscountsApiV2(gh<_i6.DiscountsManager>()));
|
||||||
gh.lazySingleton<_i26.MnemoShelf>(
|
gh.lazySingleton<_i27.MnemoShelf>(
|
||||||
() => _i26.MnemoShelf(gh<_i22.UserManager>()));
|
() => _i27.MnemoShelf(gh<_i20.UserManager>()));
|
||||||
gh.lazySingleton<_i27.PackDtoConverter>(() => _i27.PackDtoConverter(
|
gh.lazySingleton<_i28.PackDtoConverter>(() => _i28.PackDtoConverter(
|
||||||
gh<_i13.ProductsPriceResolver>(),
|
gh<_i10.ProductsPriceResolver>(),
|
||||||
gh<_i7.AdsManager>(),
|
gh<_i4.AdsManager>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i28.PackManager>(
|
gh.lazySingleton<_i29.PackManager>(() => _i29.PackManager(
|
||||||
() => _i28.PackManager(gh<_i27.PackDtoConverter>()));
|
gh<_i5.AppDatabase>(),
|
||||||
gh.lazySingleton<_i29.TestManager>(
|
gh<_i28.PackDtoConverter>(),
|
||||||
() => _i29.TestManager(gh<_i27.PackDtoConverter>()));
|
|
||||||
gh.lazySingleton<_i30.TestsApiV2>(() => _i30.TestsApiV2(
|
|
||||||
gh<_i29.TestManager>(),
|
|
||||||
gh<_i22.UserManager>(),
|
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i31.YooMoneyHandler>(
|
gh.lazySingleton<_i30.PaymentManager>(() => _i30.PaymentManager(
|
||||||
() => _i31.YooMoneyHandler(gh<_i28.PackManager>()));
|
gh<_i5.AppDatabase>(),
|
||||||
gh.lazySingleton<_i32.AdminPacksApiV2>(() => _i32.AdminPacksApiV2(
|
gh<_i29.PackManager>(),
|
||||||
gh<_i28.PackManager>(),
|
gh<_i14.SubscriptionManager>(),
|
||||||
gh<_i27.PackDtoConverter>(),
|
gh<_i21.YooMoneyHandler>(),
|
||||||
|
gh<_i11.RustorePurchaseHandler>(),
|
||||||
|
gh<_i10.ProductsPriceResolver>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i33.PacksApiV2>(() => _i33.PacksApiV2(
|
gh.lazySingleton<_i31.PromoCodesManager>(() => _i31.PromoCodesManager(
|
||||||
gh<_i28.PackManager>(),
|
gh<_i5.AppDatabase>(),
|
||||||
gh<_i29.TestManager>(),
|
gh<_i30.PaymentManager>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i34.PaymentManager>(() => _i34.PaymentManager(
|
gh.lazySingleton<_i32.PromocodesApiV2>(
|
||||||
gh<_i28.PackManager>(),
|
() => _i32.PromocodesApiV2(gh<_i31.PromoCodesManager>()));
|
||||||
gh<_i17.SubscriptionManager>(),
|
gh.lazySingleton<_i33.TestManager>(() => _i33.TestManager(
|
||||||
gh<_i31.YooMoneyHandler>(),
|
gh<_i5.AppDatabase>(),
|
||||||
gh<_i14.RustorePurchaseHandler>(),
|
gh<_i28.PackDtoConverter>(),
|
||||||
gh<_i13.ProductsPriceResolver>(),
|
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i35.PromoCodesManager>(
|
gh.lazySingleton<_i34.TestsApiV2>(() => _i34.TestsApiV2(
|
||||||
() => _i35.PromoCodesManager(gh<_i34.PaymentManager>()));
|
gh<_i33.TestManager>(),
|
||||||
gh.lazySingleton<_i36.PromocodesApiV2>(
|
gh<_i20.UserManager>(),
|
||||||
() => _i36.PromocodesApiV2(gh<_i35.PromoCodesManager>()));
|
|
||||||
gh.lazySingleton<_i37.PurchasesApiV2>(() => _i37.PurchasesApiV2(
|
|
||||||
gh<_i34.PaymentManager>(),
|
|
||||||
gh<_i28.PackManager>(),
|
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i38.UsersApiV2>(() => _i38.UsersApiV2(
|
gh.lazySingleton<_i35.UserManager>(() => _i35.UserManager(
|
||||||
gh<_i22.UserManager>(),
|
gh<_i5.AppDatabase>(),
|
||||||
gh<_i34.PaymentManager>(),
|
gh<_i7.FreePacksDistributor>(),
|
||||||
gh<_i16.StatisticsCalculator>(),
|
gh<_i12.SessionTracker>(),
|
||||||
|
gh<_i13.StatisticsCalculator>(),
|
||||||
|
gh<_i22.AchievementManager>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i39.AdminUsersApiV2>(() => _i39.AdminUsersApiV2(
|
gh.lazySingleton<_i36.UsersApiV2>(() => _i36.UsersApiV2(
|
||||||
gh<_i22.UserManager>(),
|
gh<_i20.UserManager>(),
|
||||||
gh<_i34.PaymentManager>(),
|
gh<_i30.PaymentManager>(),
|
||||||
|
gh<_i13.StatisticsCalculator>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i40.AdsApiV2>(() => _i40.AdsApiV2(
|
gh.lazySingleton<_i37.CheckPaymentTask>(
|
||||||
gh<_i7.AdsManager>(),
|
() => _i37.CheckPaymentTask(gh<_i30.PaymentManager>()));
|
||||||
gh<_i34.PaymentManager>(),
|
|
||||||
));
|
|
||||||
gh.lazySingleton<_i41.CheckPaymentTask>(
|
|
||||||
() => _i41.CheckPaymentTask(gh<_i34.PaymentManager>()));
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _$AppModule extends _i38.AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:get_it/get_it.dart';
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
|
|
||||||
import 'injector.config.dart';
|
import 'injector.config.dart';
|
||||||
|
import 'modules.dart';
|
||||||
|
|
||||||
final getIt = GetIt.instance;
|
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/shelf_io.dart' as shelf_io;
|
||||||
import 'package:shelf_router/shelf_router.dart';
|
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_analytics_api_v2.dart';
|
||||||
import 'v2/admin_auth_api_v2.dart';
|
import 'v2/admin_auth_api_v2.dart';
|
||||||
import 'v2/admin_cards_api_v2.dart';
|
// import 'v2/admin_cards_api_v2.dart'; // disabled
|
||||||
import 'v2/admin_packs_api_v2.dart';
|
// import 'v2/admin_packs_api_v2.dart'; // disabled
|
||||||
import 'v2/admin_users_api_v2.dart';
|
// import 'v2/admin_users_api_v2.dart'; // uses isar
|
||||||
import 'v2/auth_api_v2.dart';
|
import 'v2/auth_api_v2.dart';
|
||||||
import 'v2/discounts_api_v2.dart';
|
import 'v2/discounts_api_v2.dart';
|
||||||
import 'v2/games_api_v2.dart';
|
// import 'v2/games_api_v2.dart'; // disabled
|
||||||
import 'v2/packs_api_v2.dart';
|
// import 'v2/packs_api_v2.dart'; // disabled
|
||||||
import 'v2/purchases_api_v2.dart';
|
// import 'v2/purchases_api_v2.dart'; // disabled
|
||||||
import 'v2/promocodes_api_v2.dart';
|
import 'v2/promocodes_api_v2.dart';
|
||||||
import 'v2/subscriptions_api_v2.dart';
|
import 'v2/subscriptions_api_v2.dart';
|
||||||
import 'v2/tasks_api_v2.dart';
|
import 'v2/tasks_api_v2.dart';
|
||||||
import 'v2/tests_api_v2.dart';
|
import 'v2/tests_api_v2.dart';
|
||||||
import 'v2/users_api_v2.dart';
|
// import 'v2/users_api_v2.dart'; // uses isar
|
||||||
import 'v2/telegram_bot_api_v2.dart';
|
// import 'v2/telegram_bot_api_v2.dart'; // uses isar
|
||||||
import 'v2/authorize_v2.dart';
|
import 'v2/authorize_v2.dart';
|
||||||
import 'v2/telegram_bot_auth_middleware.dart';
|
import 'v2/telegram_bot_auth_middleware.dart';
|
||||||
import 'v2/jwt_service.dart';
|
import 'v2/jwt_service.dart';
|
||||||
|
|
@ -48,30 +48,20 @@ class MnemoShelf {
|
||||||
final port = int.tryParse(portArg ?? '') ?? 3000;
|
final port = int.tryParse(portArg ?? '') ?? 3000;
|
||||||
|
|
||||||
// V2 APIs (new RESTful API with OAuth2/JWT)
|
// V2 APIs (new RESTful API with OAuth2/JWT)
|
||||||
final v2Routers = [
|
final v2Router = Router();
|
||||||
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 = v2Routers.fold<Router>(
|
// Add routers individually to avoid type issues
|
||||||
Router(),
|
v2Router.mount('/', getIt.get<AuthApiV2>().router);
|
||||||
(router, child) => router..mount('/', child),
|
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
|
// Note: rootRouter is not used because we manually route to v1/v2 handlers
|
||||||
// for different authentication middleware. Keeping for reference.
|
// 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:googleapis_auth/auth_io.dart' as auth;
|
||||||
import 'package:injectable/injectable.dart';
|
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/google_play_purchase_handler.dart';
|
||||||
import 'package:mnemo_cards_backend/api/purchase/rustore/rustore_purchase_response.dart';
|
import 'package:mnemo_cards_backend/api/purchase/rustore/rustore_purchase_response.dart';
|
||||||
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
import 'package:yookassa_client/yookassa_client.dart';
|
import 'package:yookassa_client/yookassa_client.dart';
|
||||||
|
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/pack_manager.dart';
|
||||||
import '../../packs/products_price_resolver.dart';
|
import '../../packs/products_price_resolver.dart';
|
||||||
import '../subscription/subscription_manager.dart';
|
import '../subscription/subscription_manager.dart';
|
||||||
|
|
@ -24,6 +27,7 @@ import 'yoo_money.dart';
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class PaymentManager {
|
class PaymentManager {
|
||||||
|
final AppDatabase _db;
|
||||||
final PackManager _packManager;
|
final PackManager _packManager;
|
||||||
final SubscriptionManager _subscriptionManager;
|
final SubscriptionManager _subscriptionManager;
|
||||||
late final GooglePlayPurchaseHandler googlePurchaseHandler;
|
late final GooglePlayPurchaseHandler googlePurchaseHandler;
|
||||||
|
|
@ -32,6 +36,7 @@ class PaymentManager {
|
||||||
final ProductsPriceResolver _productsPriceResolver;
|
final ProductsPriceResolver _productsPriceResolver;
|
||||||
|
|
||||||
PaymentManager(
|
PaymentManager(
|
||||||
|
this._db,
|
||||||
this._packManager,
|
this._packManager,
|
||||||
this._subscriptionManager,
|
this._subscriptionManager,
|
||||||
this._yooMoneyHandler,
|
this._yooMoneyHandler,
|
||||||
|
|
@ -39,453 +44,251 @@ class PaymentManager {
|
||||||
this._productsPriceResolver,
|
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>>
|
Future<Map<String, GooglePlayPurchaseHandler>>
|
||||||
_createPurchaseHandlers() async {
|
_createPurchaseHandlers() async {
|
||||||
// Configure Android Publisher API access
|
// TODO: Implement proper Google Play purchase handlers with service account
|
||||||
final serviceAccountGooglePlay =
|
// For now, return empty map
|
||||||
File('data/service-account-google-play.json').readAsStringSync();
|
return {};
|
||||||
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<void> processPayment(Payment payment) async {
|
||||||
}
|
if (payment.status == PaymentStatus.processed.name) {
|
||||||
|
|
||||||
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');
|
log('Payment already processed');
|
||||||
return;
|
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
|
if (payment.status != PaymentStatus.succeeded.name) {
|
||||||
.where((p) => p.type == MnemoCardsProductModelType.subscription)
|
throw Exception('Payment status is not succeeded');
|
||||||
.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<PaymentModel>> getUserPayments(String userIdString) async {
|
// Получить пользователя
|
||||||
final userId = int.tryParse(userIdString);
|
final user = await _db.userDao.getUserWithDataById(payment.userId);
|
||||||
if (userId == null) return [];
|
if (user == null) {
|
||||||
return isar.txn(() async {
|
log('User not found: ${payment.userId}');
|
||||||
final user = await isar.userModels.get(userId);
|
return;
|
||||||
if (user == null) return [];
|
}
|
||||||
final purchases =
|
|
||||||
user.purchases.map((e) => int.tryParse(e)).whereNotNull();
|
// Извлечь IDs пакетов из продуктов
|
||||||
if (purchases.isEmpty) return [];
|
final packIds = <int>[];
|
||||||
final payments =
|
if (payment.products != null) {
|
||||||
(await isar.paymentModels.getAll(purchases.toList())).whereNotNull();
|
for (final product in payment.products!) {
|
||||||
return payments.toList();
|
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(
|
/// Проверить платеж Google Play
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> checkGooglePayment({
|
Future<bool> checkGooglePayment({
|
||||||
required MnemoCardsProductDto product,
|
required String productId,
|
||||||
required String token,
|
required String token,
|
||||||
required UserModel? user,
|
required UserModel? user,
|
||||||
}) async {
|
}) async {
|
||||||
final productId = product.id.toString();
|
if (user == null) return false;
|
||||||
if (product.type == MnemoCardsProductType.pack) {
|
|
||||||
final productPurchase = await googlePurchaseHandler.handleNonSubscription(
|
try {
|
||||||
productId: productId,
|
// Найти платеж по external token
|
||||||
token: token,
|
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
|
||||||
);
|
if (payment == null) {
|
||||||
if (productPurchase == null) {
|
log('Payment not found for token: $token');
|
||||||
return false;
|
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;
|
// Проверить статус в Google Play
|
||||||
if (model == null) {
|
final acknowledged = await googlePurchaseHandler.acknowledge(productId, token);
|
||||||
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);
|
|
||||||
if (!acknowledged) {
|
if (!acknowledged) {
|
||||||
await isar.writeTxn(
|
await _db.paymentDao.updatePaymentStatus(payment.id, PaymentStatus.waiting.name);
|
||||||
() => isar.paymentModels.put(
|
return false;
|
||||||
updatedModel.copyWith(status: PaymentStatus.waiting),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обновить статус платежа
|
||||||
|
await _db.paymentDao.updatePaymentStatus(payment.id, PaymentStatus.succeeded.name);
|
||||||
|
|
||||||
|
// Обработать платеж
|
||||||
|
await processPayment(payment);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
log('Error checking Google payment: $e');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Проверить платеж RuStore
|
||||||
Future<bool> checkRustorePayment({
|
Future<bool> checkRustorePayment({
|
||||||
required String productId,
|
required String productId,
|
||||||
required String subscriptionToken,
|
required String subscriptionToken,
|
||||||
required UserModel? user,
|
required UserModel? user,
|
||||||
}) async {
|
}) async {
|
||||||
final rustorePurchaseResponse =
|
if (user == null) return false;
|
||||||
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;
|
try {
|
||||||
if (model == null) {
|
final rustorePurchaseResponse =
|
||||||
updatedModel = PaymentModel.rustore(
|
await _rustorePurchaseHandler.checkPayment(subscriptionToken);
|
||||||
amount: pack?.price ?? 'no-price',
|
|
||||||
currency: 'rub',
|
// Найти платеж по продукту
|
||||||
userId: user!.id!,
|
final payments = await _db.paymentDao.getPaymentsByProduct(productId);
|
||||||
externalToken: subscriptionToken,
|
final payment = payments.isNotEmpty ? payments.first : null;
|
||||||
date: DateTime.now(),
|
|
||||||
products: [
|
if (payment == null) {
|
||||||
MnemoCardsProductModelBase.pack(pack?.id),
|
log('Payment not found for product: $productId');
|
||||||
],
|
|
||||||
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);
|
|
||||||
return false;
|
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 {
|
/// Проверить платеж YooKassa
|
||||||
var paymentStatus = model.status;
|
Future<bool> checkYookassaPayment(String token) async {
|
||||||
if (model.status == PaymentStatus.created) {
|
try {
|
||||||
if (model.paymentSystem == PaymentSystem.rustore) {
|
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
|
||||||
if (model.externalToken != null) {
|
if (payment == null) {
|
||||||
final payment =
|
log('Payment not found for token: $token');
|
||||||
await _rustorePurchaseHandler.checkPayment(model.externalToken!);
|
return false;
|
||||||
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(
|
// Проверить статус в YooKassa
|
||||||
status: paymentStatus,
|
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(
|
final paymentDto = PaymentDto(
|
||||||
'Processing ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}');
|
amount: amount,
|
||||||
try {
|
currency: 'RUB',
|
||||||
processPayment(updatedModel);
|
date: DateTime.now(),
|
||||||
} catch (error) {
|
status: PaymentStatus.created,
|
||||||
print(error);
|
paymentSystem: PaymentSystem.yookassa,
|
||||||
print(
|
packs: [],
|
||||||
'Process error ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}');
|
subscription: false,
|
||||||
return model;
|
products: [], // TODO: add products
|
||||||
}
|
externalToken: yookassaPayment.id,
|
||||||
await isar.writeTxn(() => isar.paymentModels.put(updatedModel));
|
meta: null,
|
||||||
print(
|
);
|
||||||
'Processed ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}',
|
|
||||||
);
|
await createPayment(paymentDto, int.parse(userId));
|
||||||
} else if (updatedModel.status == PaymentStatus.unknown) {
|
|
||||||
await isar.writeTxn(() => isar.paymentModels.put(updatedModel));
|
return yookassaPayment.confirmationUrl!;
|
||||||
}
|
|
||||||
return updatedModel;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/// Получить платежи пользователя
|
||||||
|
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: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';
|
/// Wrapper for YooKassa payment (simplified)
|
||||||
import '../../packs/pack_manager.dart';
|
class YookassaPayment {
|
||||||
|
final String id;
|
||||||
|
final String status;
|
||||||
|
final String? confirmationUrl;
|
||||||
|
|
||||||
|
YookassaPayment({
|
||||||
|
required this.id,
|
||||||
|
required this.status,
|
||||||
|
this.confirmationUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@LazySingleton()
|
|
||||||
class YooMoneyHandler {
|
class YooMoneyHandler {
|
||||||
final testKey = 'test_Px7SnKrsT0fO8ZKXmnzNqMknuHne7LwG-ypKhJnw9Ro';
|
final String _shopId;
|
||||||
final testShop = '382060';
|
final String _secretKey;
|
||||||
|
|
||||||
final key = 'live_17KhhN5jmobRyjS2LKHPOBVs6noAtJYCg4Wyd_-3byQ';
|
YooMoneyHandler({
|
||||||
final shop = '380354';
|
required String shopId,
|
||||||
|
required String secretKey,
|
||||||
|
}) : _shopId = shopId,
|
||||||
|
_secretKey = secretKey;
|
||||||
|
|
||||||
late final _yookassaClient = YookassaClient(
|
Future<YookassaPayment> createPayment({
|
||||||
Dio(),
|
required String amount,
|
||||||
credentials: YookassaAuthCredentials(
|
required String description,
|
||||||
shopId: shop,
|
required String userId,
|
||||||
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 {
|
}) async {
|
||||||
if (user.id == null) {
|
// TODO: Implement real YooKassa API integration
|
||||||
return null;
|
// For now, return stub
|
||||||
}
|
return YookassaPayment(
|
||||||
|
id: 'test_payment_${DateTime.now().millisecondsSinceEpoch}',
|
||||||
final reservedPaymentId = await isar.writeTxn(
|
status: 'pending',
|
||||||
() => isar.paymentModels.put(
|
confirmationUrl: 'https://yookassa.ru/payment/test',
|
||||||
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 {
|
Future<YookassaPayment> checkPayment(String paymentId) async {
|
||||||
final payment = await yookassaClient.getPaymentInfo(
|
// TODO: Implement real YooKassa API checking
|
||||||
paymentId: yookassaId,
|
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:injectable/injectable.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:mnemo_cards_backend/database/database.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_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
|
||||||
import '../../main.dart';
|
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class SubscriptionManager {
|
class SubscriptionManager {
|
||||||
|
final AppDatabase _db;
|
||||||
|
|
||||||
|
SubscriptionManager(this._db);
|
||||||
|
|
||||||
Future<SubscriptionDto> getSubscriptionDto(UserModel user) async {
|
Future<SubscriptionDto> getSubscriptionDto(UserModel user) async {
|
||||||
await user.subscriptionModel.load();
|
// TODO: Implement with Drift
|
||||||
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(
|
return SubscriptionDto(
|
||||||
page: SubscriptionPageDto(
|
page: null,
|
||||||
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,
|
isActive: false,
|
||||||
start: null,
|
start: null,
|
||||||
finish: null,
|
finish: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<SubscriptionPlanAdminDto>> getAllSubscriptionPlans() async {
|
Future<SubscriptionPlanModel?> getSubscriptionPlan(String id) async {
|
||||||
final models =
|
final intId = int.tryParse(id);
|
||||||
await isar.txn(() => isar.subscriptionPlanModels.where().findAll());
|
if (intId == null) return null;
|
||||||
return Future.wait(models.map((model) => model.toAdminDto()));
|
|
||||||
|
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 {
|
Future<void> createSubscription(UserModel user, SubscriptionPlanModel plan) async {
|
||||||
final intId = int.parse(id);
|
// TODO: Implement with Drift
|
||||||
return isar.writeTxn(() {
|
throw UnimplementedError('SubscriptionManager.createSubscription not implemented');
|
||||||
return isar.subscriptionPlanModels.delete(intId);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> addSubscriptionPlans(SubscriptionPlanAdminDto dto) async {
|
Future<List<SubscriptionPlanModel>> getAllPlans() async {
|
||||||
final model = await int.tryParse(dto.id ?? '')
|
// TODO: Implement with Drift
|
||||||
?.let((id) => isar.subscriptionPlanModels.get(id)) ??
|
return [];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
// 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
|
// Get all packs and filter in memory
|
||||||
final packsQuery = backend_main.isar.cardPackModels.where();
|
final enabledPacks = await backend_main.database.packDao.getAllPacks(enabledOnly: true);
|
||||||
// Note: Using a different approach since findAll may not be available
|
final enabledPackCount = enabledPacks.length;
|
||||||
final enabledPackCount = 0; // Temporary placeholder
|
|
||||||
|
|
||||||
final paymentCount = await backend_main.isar.paymentModels.count();
|
final paymentCount = await backend_main.database.paymentDao.countAllPayments();
|
||||||
|
|
||||||
// Get recent users (last 10)
|
// Get recent users (last 10)
|
||||||
final allUsers = await backend_main.isar
|
final allUsers = await backend_main.database.userDao.getAllUsers(limit: 10);
|
||||||
.txn(() async => backend_main.isar.userModels.where().findAll());
|
final recentUsers = allUsers
|
||||||
final sortedUsers = allUsers
|
|
||||||
..sort((a, b) => (b.id ?? 0).compareTo(a.id ?? 0));
|
|
||||||
final recentUsers = sortedUsers
|
|
||||||
.take(10)
|
|
||||||
.map((u) => {
|
.map((u) => {
|
||||||
'id': u.id,
|
'id': u.id,
|
||||||
'name': u.name,
|
'name': u.name,
|
||||||
'email': u.email,
|
'email': u.email,
|
||||||
'createdAt': DateTime(1999).toIso8601String(),
|
'createdAt': u.createdAt.toIso8601String(),
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Get top packs by user count (mock data for now)
|
// Get top packs by user count (mock data for now)
|
||||||
final allPacks = await backend_main.isar
|
final allPacks = await backend_main.database.packDao.getAllPacks();
|
||||||
.txn(() async => backend_main.isar.cardPackModels.where().findAll());
|
final topPacksList = allPacks.take(5);
|
||||||
final topPacks = allPacks
|
final topPacks = topPacksList
|
||||||
.take(5)
|
|
||||||
.map((p) => {
|
.map((p) => {
|
||||||
'id': p.id,
|
'id': p.id,
|
||||||
'title': p.title,
|
'title': p.title,
|
||||||
'cards': p.cards.length,
|
'cards': 0, // TODO: get card count for pack
|
||||||
'enabled': p.enabled,
|
'enabled': p.enabled,
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
|
||||||
|
|
@ -1,299 +1,203 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
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/shelf.dart';
|
||||||
import 'package:shelf_router/shelf_router.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';
|
part 'admin_cards_api_v2.g.dart';
|
||||||
|
|
||||||
/// Admin endpoints for card management in API v2.
|
@injectable
|
||||||
@lazySingleton
|
|
||||||
class AdminCardsApiV2 {
|
class AdminCardsApiV2 {
|
||||||
AdminCardsApiV2();
|
final AppDatabase _db;
|
||||||
|
|
||||||
Response _json(
|
const AdminCardsApiV2(this._db);
|
||||||
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(
|
@Route.get('/cards')
|
||||||
body: jsonEncode({'error': 'Bad Request', 'message': message}),
|
Future<Response> getAllCards(Request request) async {
|
||||||
headers: {'Content-Type': 'application/json'},
|
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(
|
final cards = packId != null
|
||||||
jsonEncode({
|
? await _db.packDao.getPackCards(int.parse(packId))
|
||||||
'error': 'Not Found',
|
: await _db.packDao.getAllCards(limit: limit, offset: offset);
|
||||||
'message': message ?? 'Resource not found',
|
|
||||||
|
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'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
Response _internalServerError([String? message]) => Response(
|
return Response.internalServerError(
|
||||||
500,
|
body: json.encode({'error': e.toString()}),
|
||||||
body: jsonEncode({
|
|
||||||
'error': 'Internal Server Error',
|
|
||||||
'message': message ?? 'An error occurred',
|
|
||||||
}),
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
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
|
@Route.get('/cards/<cardId>')
|
||||||
/// 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 {
|
Future<Response> getCard(Request request, String cardId) async {
|
||||||
try {
|
try {
|
||||||
final auth = await _ensureAdmin(request);
|
final id = int.tryParse(cardId);
|
||||||
if (auth.statusCode != 200) {
|
if (id == null) {
|
||||||
return auth;
|
return Response.badRequest(
|
||||||
|
body: json.encode({'error': 'Invalid card ID'}),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final cardIdInt = int.tryParse(cardId);
|
final card = await _db.packDao.getCardById(id);
|
||||||
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) {
|
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());
|
return Response.ok(
|
||||||
} catch (e, s) {
|
json.encode({
|
||||||
print('Error in getCard: $e\n$s');
|
'id': card.id,
|
||||||
return _internalServerError(e.toString());
|
'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
|
@Route.post('/cards')
|
||||||
/// Create or update a card
|
Future<Response> createCard(Request request) async {
|
||||||
@Route.post('/admin/cards')
|
|
||||||
Future<Response> upsertCard(Request request) async {
|
|
||||||
try {
|
try {
|
||||||
final auth = await _ensureAdmin(request);
|
final body = await request.readAsString();
|
||||||
if (auth.statusCode != 200) {
|
final data = json.decode(body) as Map<String, dynamic>;
|
||||||
return auth;
|
|
||||||
|
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();
|
final body = await request.readAsString();
|
||||||
if (body.isEmpty) {
|
final data = json.decode(body) as Map<String, dynamic>;
|
||||||
return _json(
|
|
||||||
{
|
final existing = await _db.packDao.getCardById(id);
|
||||||
'error': 'bad_request',
|
if (existing == null) {
|
||||||
'message': 'Card payload is required',
|
return Response.notFound(
|
||||||
},
|
json.encode({'error': 'Card not found'}),
|
||||||
statusCode: 400,
|
headers: {'Content-Type': 'application/json'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
late final GameCardDto cardDto;
|
final updated = existing.copyWith(
|
||||||
try {
|
original: data['original'] ?? existing.original,
|
||||||
cardDto =
|
translation: data['translation'] ?? existing.translation,
|
||||||
GameCardDto.fromJson(jsonDecode(body) as Map<String, dynamic>);
|
mnemo: data['mnemo'] ?? existing.mnemo,
|
||||||
} catch (_) {
|
image: data['image'] ?? existing.image,
|
||||||
return _json(
|
back: data['back'] ?? existing.back,
|
||||||
{
|
transcription: data['transcription'] ?? existing.transcription,
|
||||||
'error': 'bad_request',
|
updatedAt: DateTime.now(),
|
||||||
'message': 'Invalid card payload',
|
);
|
||||||
},
|
|
||||||
statusCode: 400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate required fields
|
await _db.packDao.updateCard(updated);
|
||||||
if (cardDto.original?.isEmpty ?? true) {
|
|
||||||
return _json(
|
|
||||||
{
|
|
||||||
'error': 'bad_request',
|
|
||||||
'message': 'Original text is required',
|
|
||||||
},
|
|
||||||
statusCode: 400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cardDto.translation?.isEmpty ?? true) {
|
return Response.ok(
|
||||||
return _json(
|
json.encode({'success': true}),
|
||||||
{
|
headers: {'Content-Type': 'application/json'},
|
||||||
'error': 'bad_request',
|
);
|
||||||
'message': 'Translation is required',
|
} catch (e) {
|
||||||
},
|
return Response.internalServerError(
|
||||||
statusCode: 400,
|
body: json.encode({'error': e.toString(), 'success': false}),
|
||||||
);
|
headers: {'Content-Type': 'application/json'},
|
||||||
}
|
);
|
||||||
|
|
||||||
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
|
@Route.delete('/cards/<cardId>')
|
||||||
/// Delete a card by ID
|
|
||||||
@Route.delete('/admin/cards/<cardId>')
|
|
||||||
Future<Response> deleteCard(Request request, String cardId) async {
|
Future<Response> deleteCard(Request request, String cardId) async {
|
||||||
try {
|
try {
|
||||||
final auth = await _ensureAdmin(request);
|
final id = int.tryParse(cardId);
|
||||||
if (auth.statusCode != 200) {
|
if (id == null) {
|
||||||
return auth;
|
return Response.badRequest(
|
||||||
|
body: json.encode({'error': 'Invalid card ID'}),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final cardIdInt = int.tryParse(cardId);
|
await _db.packDao.deleteCard(id);
|
||||||
if (cardIdInt == null) {
|
|
||||||
return _badRequest('Invalid card ID');
|
|
||||||
}
|
|
||||||
|
|
||||||
final card = await backend_main.isar.txn(() async {
|
return Response.ok(
|
||||||
return await backend_main.isar.gameCardModels.get(cardIdInt);
|
json.encode({'success': true}),
|
||||||
});
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
if (card == null) {
|
} catch (e) {
|
||||||
return _notFound('Card not found');
|
return Response.internalServerError(
|
||||||
}
|
body: json.encode({'error': e.toString(), 'success': false}),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
// 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);
|
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();
|
final router = Router();
|
||||||
router.add(
|
router.add(
|
||||||
'GET',
|
'GET',
|
||||||
r'/admin/cards',
|
r'/cards',
|
||||||
service.getCards,
|
service.getAllCards,
|
||||||
);
|
);
|
||||||
router.add(
|
router.add(
|
||||||
'GET',
|
'GET',
|
||||||
r'/admin/cards/<cardId>',
|
r'/cards/<cardId>',
|
||||||
service.getCard,
|
service.getCard,
|
||||||
);
|
);
|
||||||
router.add(
|
router.add(
|
||||||
'POST',
|
'POST',
|
||||||
r'/admin/cards',
|
r'/cards',
|
||||||
service.upsertCard,
|
service.createCard,
|
||||||
|
);
|
||||||
|
router.add(
|
||||||
|
'PUT',
|
||||||
|
r'/cards/<cardId>',
|
||||||
|
service.updateCard,
|
||||||
);
|
);
|
||||||
router.add(
|
router.add(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
r'/admin/cards/<cardId>',
|
r'/cards/<cardId>',
|
||||||
service.deleteCard,
|
service.deleteCard,
|
||||||
);
|
);
|
||||||
return router;
|
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/v2/jwt_service.dart';
|
||||||
import 'package:mnemo_cards_backend/api/user/google_api.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/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/telegram.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_manager.dart';
|
import 'package:mnemo_cards_backend/user/user_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_model.dart';
|
import 'package:mnemo_cards_backend/user/user_model.dart';
|
||||||
|
|
@ -20,12 +21,14 @@ part 'auth_api_v2.g.dart';
|
||||||
/// Implements OAuth2/JWT Bearer token authentication
|
/// Implements OAuth2/JWT Bearer token authentication
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class AuthApiV2 {
|
class AuthApiV2 {
|
||||||
|
final AppDatabase _db;
|
||||||
final UserManager _userManager;
|
final UserManager _userManager;
|
||||||
final GoogleApi _googleApi;
|
final GoogleApi _googleApi;
|
||||||
final JwtService _jwtService;
|
final JwtService _jwtService;
|
||||||
final TelegramAuthCodeService _telegramAuthCodeService;
|
final TelegramAuthCodeService _telegramAuthCodeService;
|
||||||
|
|
||||||
AuthApiV2(
|
AuthApiV2(
|
||||||
|
this._db,
|
||||||
this._userManager,
|
this._userManager,
|
||||||
this._googleApi,
|
this._googleApi,
|
||||||
this._jwtService,
|
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:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_backend/main.dart' as main;
|
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
|
|
||||||
/// Service for JWT token generation and verification
|
/// 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)
|
/// Generates access tokens (short-lived) and refresh tokens (long-lived)
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class JwtService {
|
class JwtService {
|
||||||
// In production, use environment variables or secure key management
|
// Read secrets from environment variables
|
||||||
static const String _secretKey = 'your-secret-key-change-in-production';
|
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 _accessTokenExpirySeconds = 3600; // 1 hour
|
||||||
static const int _refreshTokenExpirySeconds = 2592000; // 30 days
|
static const int _refreshTokenExpirySeconds = 2592000; // 30 days
|
||||||
|
|
||||||
JwtService();
|
final AppDatabase _db;
|
||||||
|
|
||||||
|
JwtService(this._db);
|
||||||
|
|
||||||
/// Generate access and refresh tokens for a user
|
/// Generate access and refresh tokens for a user
|
||||||
Future<JwtTokens> generateTokens(UserModel user) async {
|
Future<JwtTokens> generateTokens(UserModel user) async {
|
||||||
|
|
@ -141,9 +147,13 @@ class JwtService {
|
||||||
final headerB64 = base64UrlEncode(utf8.encode(jsonEncode(header)));
|
final headerB64 = base64UrlEncode(utf8.encode(jsonEncode(header)));
|
||||||
final payloadB64 = base64UrlEncode(utf8.encode(jsonEncode(payload)));
|
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 signatureInput = '$headerB64.$payloadB64';
|
||||||
final signature = _hmacSha256(utf8.encode(signatureInput), _secretKey);
|
final signature = _hmacSha256(utf8.encode(signatureInput), secret);
|
||||||
final signatureB64 = base64UrlEncode(signature);
|
final signatureB64 = base64UrlEncode(signature);
|
||||||
|
|
||||||
return '$headerB64.$payloadB64.$signatureB64';
|
return '$headerB64.$payloadB64.$signatureB64';
|
||||||
|
|
@ -162,10 +172,17 @@ class JwtService {
|
||||||
final payloadB64 = parts[1];
|
final payloadB64 = parts[1];
|
||||||
final signatureB64 = parts[2];
|
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 signatureInput = '$headerB64.$payloadB64';
|
||||||
final expectedSignature =
|
final expectedSignature = _hmacSha256(utf8.encode(signatureInput), secret);
|
||||||
_hmacSha256(utf8.encode(signatureInput), _secretKey);
|
|
||||||
final expectedSignatureB64 = base64UrlEncode(expectedSignature);
|
final expectedSignatureB64 = base64UrlEncode(expectedSignature);
|
||||||
|
|
||||||
if (signatureB64 != expectedSignatureB64) {
|
if (signatureB64 != expectedSignatureB64) {
|
||||||
|
|
@ -174,9 +191,7 @@ class JwtService {
|
||||||
return null; // Invalid signature
|
return null; // Invalid signature
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode payload
|
return payload;
|
||||||
final payloadJson = utf8.decode(base64Url.decode(payloadB64));
|
|
||||||
return jsonDecode(payloadJson) as Map<String, dynamic>;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
lastVerificationError = 'Parse exception: $e';
|
lastVerificationError = 'Parse exception: $e';
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -218,32 +233,27 @@ class JwtService {
|
||||||
DateTime createdAt,
|
DateTime createdAt,
|
||||||
DateTime expiresAt,
|
DateTime expiresAt,
|
||||||
) async {
|
) 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)
|
// Delete old token if exists (shouldn't happen due to unique index, but be safe)
|
||||||
final existing = await main.isar.refreshTokenModels
|
final existing = await _db.userDao.getRefreshTokenByJti(jti);
|
||||||
.filter()
|
|
||||||
.jtiEqualTo(jti)
|
|
||||||
.findFirst();
|
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
await main.isar.refreshTokenModels.delete(existing.id!);
|
await _db.userDao.revokeRefreshToken(existing.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store new token
|
// Store new token
|
||||||
await main.isar.refreshTokenModels.put(
|
await _db.userDao.createRefreshToken(
|
||||||
RefreshTokenModel(
|
RefreshTokensCompanion.insert(
|
||||||
jti: jti,
|
jti: jti,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
createdAt: createdAt,
|
expiresAt: expiresAt, // required field - raw DateTime
|
||||||
expiresAt: expiresAt,
|
// createdAt and isBlacklisted use defaults from table
|
||||||
isBlacklisted: false,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _isTokenBlacklisted(String jti) async {
|
Future<bool> _isTokenBlacklisted(String jti) async {
|
||||||
final token =
|
final token = await _db.userDao.getRefreshTokenByJti(jti);
|
||||||
await main.isar.refreshTokenModels.filter().jtiEqualTo(jti).findFirst();
|
|
||||||
|
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
return true; // Token not found, consider it invalid
|
return true; // Token not found, consider it invalid
|
||||||
|
|
@ -259,38 +269,12 @@ class JwtService {
|
||||||
|
|
||||||
/// Blacklist a refresh token (for logout)
|
/// Blacklist a refresh token (for logout)
|
||||||
Future<void> blacklistRefreshToken(String jti) async {
|
Future<void> blacklistRefreshToken(String jti) async {
|
||||||
await main.isar.writeTxn(() async {
|
await _db.userDao.revokeRefreshTokenByJti(jti);
|
||||||
final token = await main.isar.refreshTokenModels
|
|
||||||
.filter()
|
|
||||||
.jtiEqualTo(jti)
|
|
||||||
.findFirst();
|
|
||||||
if (token != null) {
|
|
||||||
await main.isar.refreshTokenModels.put(
|
|
||||||
token.copyWith(isBlacklisted: true),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clean up expired tokens (should be called periodically)
|
/// Clean up expired tokens (should be called periodically)
|
||||||
Future<void> cleanupExpiredTokens() async {
|
Future<void> cleanupExpiredTokens() async {
|
||||||
await main.isar.writeTxn(() async {
|
await _db.userDao.deleteExpiredRefreshTokens();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 'dart:developer';
|
||||||
|
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:mnemo_cards_backend/tasks/task_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
|
||||||
import 'package:mnemo_cards_backend/main.dart' as backend_main;
|
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:shelf_router/shelf_router.dart';
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
|
||||||
import '../../main.dart';
|
|
||||||
|
|
||||||
part 'tasks_api_v2.g.dart';
|
part 'tasks_api_v2.g.dart';
|
||||||
|
|
||||||
/// API v2 endpoints for user tasks management
|
/// API v2 endpoints for user tasks management
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class TasksApiV2 {
|
class TasksApiV2 {
|
||||||
const TasksApiV2();
|
final TaskManager _taskManager;
|
||||||
|
|
||||||
|
const TasksApiV2(this._taskManager);
|
||||||
|
|
||||||
Response _json(
|
Response _json(
|
||||||
Object? data, {
|
Object? data, {
|
||||||
|
|
@ -82,40 +80,44 @@ class TasksApiV2 {
|
||||||
final limit = int.tryParse(queryParams['limit'] ?? '50') ?? 50;
|
final limit = int.tryParse(queryParams['limit'] ?? '50') ?? 50;
|
||||||
final offset = int.tryParse(queryParams['offset'] ?? '0') ?? 0;
|
final offset = int.tryParse(queryParams['offset'] ?? '0') ?? 0;
|
||||||
|
|
||||||
// Build query
|
// Get tasks using TaskManager
|
||||||
var builder = isar.userTaskModels.where().filter();
|
final tasks = await _taskManager.getUserTasks(
|
||||||
|
userId,
|
||||||
|
type: type,
|
||||||
|
difficulty: difficulty,
|
||||||
|
status: status,
|
||||||
|
tags: tags,
|
||||||
|
limit: limit,
|
||||||
|
offset: offset,
|
||||||
|
);
|
||||||
|
|
||||||
if (type != null) {
|
// Convert to JSON format
|
||||||
builder = builder.typeEqualTo(type);
|
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) {
|
return _json({
|
||||||
builder = builder.difficultyEqualTo(difficulty);
|
'tasks': tasksJson,
|
||||||
}
|
'total': tasks.length, // TODO: Get actual total count
|
||||||
|
'limit': limit,
|
||||||
if (status != null) {
|
'offset': offset,
|
||||||
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()});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('Error fetching tasks: $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);
|
final taskIdInt = int.tryParse(taskId);
|
||||||
if (taskIdInt == null) return _badRequest('Invalid task ID');
|
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');
|
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) {
|
} catch (e) {
|
||||||
log('Error fetching task: $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);
|
final taskIdInt = int.tryParse(taskId);
|
||||||
if (taskIdInt == null) return _badRequest('Invalid task ID');
|
if (taskIdInt == null) return _badRequest('Invalid task ID');
|
||||||
|
|
||||||
final task = await isar.userTaskModels.get(taskIdInt);
|
// Start task using TaskManager
|
||||||
if (task == null) return _notFound('Task not found');
|
await _taskManager.startTask(userId, taskIdInt);
|
||||||
|
|
||||||
if (!task.isActive) return _badRequest('Task is not available');
|
return _json({'success': true, 'message': 'Task started successfully'});
|
||||||
|
|
||||||
// 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'
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('Error starting task: $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);
|
final taskIdInt = int.tryParse(taskId);
|
||||||
if (taskIdInt == null) return _badRequest('Invalid task ID');
|
if (taskIdInt == null) return _badRequest('Invalid task ID');
|
||||||
|
|
||||||
final body = await request.readAsString();
|
// Complete task using TaskManager
|
||||||
final data = jsonDecode(body) as Map<String, dynamic>;
|
await _taskManager.completeTask(userId, taskIdInt);
|
||||||
|
|
||||||
final proofUrl = data['proofUrl'] as String?;
|
return _json({'success': true, 'message': 'Task completed successfully'});
|
||||||
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(),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('Error completing task: $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
|
/// GET /api/v2/users/me/tasks/progress - Get user task progress
|
||||||
@Route.get('/users/me/tasks/progress')
|
@Route.get('/users/me/tasks/progress')
|
||||||
Future<Response> getUserProgress(Request request) async {
|
Future<Response> getUserTaskProgress(Request request) async {
|
||||||
final userId = request.user?.id;
|
final userId = request.user?.id;
|
||||||
if (userId == null) return _unauthorized();
|
if (userId == null) return _unauthorized();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final progress = await isar.userTaskProgressModels
|
final progress = await _taskManager.getUserTaskProgress(userId);
|
||||||
.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();
|
|
||||||
|
|
||||||
return _json({
|
return _json({
|
||||||
'categories': {
|
'progress': progress.map((p) => {
|
||||||
'types': types,
|
'taskId': p.taskId,
|
||||||
'difficulties': difficulties,
|
'progress': p.progress,
|
||||||
'tags': allTags,
|
'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) {
|
} catch (e) {
|
||||||
log('Error fetching task categories: $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
|
Handler get handler => _$TasksApiV2Router(this);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -31,7 +31,7 @@ Router _$TasksApiV2Router(TasksApiV2 service) {
|
||||||
router.add(
|
router.add(
|
||||||
'GET',
|
'GET',
|
||||||
r'/users/me/tasks/progress',
|
r'/users/me/tasks/progress',
|
||||||
service.getUserProgress,
|
service.getUserTaskProgress,
|
||||||
);
|
);
|
||||||
router.add(
|
router.add(
|
||||||
'GET',
|
'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:developer';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:isar/isar.dart';
|
|
||||||
import 'package:mnemo_cards_backend/api/mnemo_shelf.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/add_free_packs.dart';
|
||||||
import 'package:mnemo_cards_backend/cron/backup.dart';
|
import 'package:mnemo_cards_backend/cron/backup.dart';
|
||||||
import 'package:mnemo_cards_backend/cron/cron_executor.dart';
|
import 'package:mnemo_cards_backend/cron/cron_executor.dart';
|
||||||
import 'package:mnemo_cards_backend/cron/generate_promocodes.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/discounts/discounts_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_manager.dart';
|
import 'package:mnemo_cards_backend/user/user_manager.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
|
||||||
import 'package:mnemo_cards_backend/tests/test_manager.dart';
|
import 'package:mnemo_cards_backend/tests/test_manager.dart';
|
||||||
|
|
||||||
import 'api/di/injector.dart';
|
import 'api/di/injector.dart';
|
||||||
|
|
@ -22,44 +21,56 @@ import 'cron/test_generator.dart';
|
||||||
import 'cron/update_online_users.dart';
|
import 'cron/update_online_users.dart';
|
||||||
import 'packs/free_packs_distributor.dart';
|
import 'packs/free_packs_distributor.dart';
|
||||||
|
|
||||||
late Isar isar;
|
late AppDatabase database;
|
||||||
|
|
||||||
late final String WORK_DIR;
|
late final String WORK_DIR;
|
||||||
|
|
||||||
|
/// Инициализация подключения к PostgreSQL
|
||||||
Future<Isar> _initIsar(
|
Future<AppDatabase> _initDatabase() async {
|
||||||
String dir,
|
|
||||||
bool debugMode,
|
|
||||||
) async {
|
|
||||||
var attempt = 0;
|
var attempt = 0;
|
||||||
while (attempt < 5) {
|
const maxAttempts = 5;
|
||||||
print('Attempt $attempt to initialize Isar');
|
|
||||||
try {
|
while (attempt < maxAttempts) {
|
||||||
return isar = await IsarConnector().connect(
|
print('Attempt ${attempt + 1}/$maxAttempts to connect to PostgreSQL...');
|
||||||
dir: dir,
|
|
||||||
name: 'db',
|
try {
|
||||||
inspector: debugMode,
|
// Создать подключение из environment variables
|
||||||
);
|
final db = AppDatabase.fromEnvironment();
|
||||||
} catch (e, s) {
|
|
||||||
print('Error initializing Isar: $e');
|
// Проверить подключение
|
||||||
log('Error initializing Isar: $e', error: e, stackTrace: s);
|
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 connect to PostgreSQL after $maxAttempts attempts');
|
||||||
}
|
|
||||||
throw Exception('Failed to initialize Isar');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
// WidgetsFlutterBinding.ensureInitialized();
|
print('🚀 Starting Mnemo Cards Backend...');
|
||||||
// final dir = (await getApplicationDocumentsDirectory()).path;
|
|
||||||
|
// Читаем environment variables
|
||||||
// Чтение переменных окружения вместо CLI аргументов
|
final backupDir = Platform.environment['BACKUP_DIR'] ?? '../backups/';
|
||||||
final dir = Platform.environment['ISAR_DIR'] ?? 'isar';
|
|
||||||
final backupDir = Platform.environment['BACKUP_DIR'] ??
|
|
||||||
'../mnemo_cards_telegram_bot/backups/';
|
|
||||||
WORK_DIR = Platform.environment['WORK_DIR'] ?? '/root/mnemo_cards_backend';
|
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(
|
print(
|
||||||
'running server in ${(await Process.run('pwd', [], runInShell: true)).stdout}',
|
'running server in ${(await Process.run('pwd', [], runInShell: true)).stdout}',
|
||||||
);
|
);
|
||||||
|
|
@ -75,45 +86,61 @@ void main() async {
|
||||||
);
|
);
|
||||||
print(pythonInit.stdout);
|
print(pythonInit.stdout);
|
||||||
print(pythonInit.stderr);
|
print(pythonInit.stderr);
|
||||||
// if (pythonInit.stderr != null) {
|
|
||||||
// throw Exception('Not inited');
|
|
||||||
// }
|
|
||||||
} else {
|
} else {
|
||||||
log('\n\n*******\nRUSTORE IS DISABLED\n******\n\n', level: 1000);
|
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 {
|
// Инициализация PostgreSQL
|
||||||
getIt.registerSingleton<Isar>(isar);
|
try {
|
||||||
configureDependencies();
|
await _initDatabase();
|
||||||
await getIt<MnemoShelf>().initV2();
|
|
||||||
|
// Регистрация в DI
|
||||||
// ignore: unawaited_futures
|
getIt.registerSingleton<AppDatabase>(database);
|
||||||
CronManager([
|
|
||||||
DeleteOldArchives(),
|
// Настройка остальных зависимостей
|
||||||
CheckAdminsTask(),
|
configureDependencies();
|
||||||
TestGeneratorTask(getIt.get<TestManager>()),
|
|
||||||
getIt.get<CheckPaymentTask>(),
|
// Запуск API сервера
|
||||||
AddFreePacks(getIt.get<FreePacksDistributor>()),
|
print('🌐 Starting API server...');
|
||||||
Backup(backupDir),
|
await getIt<MnemoShelf>().initV2();
|
||||||
GeneratePromocodes(),
|
|
||||||
DiscountCampaignTask(getIt.get<DiscountsManager>()),
|
// Запуск cron jobs
|
||||||
UpdateOnlineUsersTask(getIt.get<UserManager>()),
|
print('⏰ Starting cron jobs...');
|
||||||
TasksSeederTask(),
|
// ignore: unawaited_futures
|
||||||
]).init();
|
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 {
|
ProcessSignal.sigterm.watch().listen((signal) async {
|
||||||
|
print('🛑 Received SIGTERM, shutting down gracefully...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await isar.close();
|
// Закрыть подключение к БД
|
||||||
|
await database.close();
|
||||||
|
print('✅ Database connection closed');
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
log('Error closing Isar: $e', error: e, stackTrace: s);
|
print('❌ Error closing database: $e');
|
||||||
print('Error closing Isar: $e');
|
log('Error closing database: $e', error: e, stackTrace: s);
|
||||||
}
|
}
|
||||||
|
|
||||||
exit(0);
|
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:async';
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:image/image.dart';
|
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.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 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
|
||||||
import '../main.dart';
|
|
||||||
import 'pack_dto_converter.dart';
|
import 'pack_dto_converter.dart';
|
||||||
|
import 'pack_manager_extensions.dart';
|
||||||
|
import 'card_pack_drift_extension.dart';
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class PackManager {
|
class PackManager {
|
||||||
|
final AppDatabase _db;
|
||||||
final PackDtoConverter packDtoConverter;
|
final PackDtoConverter packDtoConverter;
|
||||||
|
|
||||||
PackManager(this.packDtoConverter);
|
PackManager(this._db, this.packDtoConverter);
|
||||||
|
|
||||||
Future<List<CardPackPreviewDto>> listPacksPreviews(
|
Future<List<CardPackPreviewDto>> listPacksPreviews(
|
||||||
UserModel? userModel,
|
UserModel? userModel,
|
||||||
Map<String, String>? params,
|
Map<String, String>? params,
|
||||||
) async {
|
) async {
|
||||||
final models = await isar.cardPackModels
|
// Get packs from Drift database
|
||||||
.filter()
|
final packs = await _db.packDao.getAllPacks(
|
||||||
.optional(
|
enabledOnly: true,
|
||||||
userModel?.admin != true,
|
orderByField: 'order',
|
||||||
(q) => q.enabledEqualTo(true),
|
);
|
||||||
)
|
|
||||||
.findAll();
|
return (await Future.wait(packs.map(
|
||||||
models.sort((p, n) => p.order.compareTo(n.order));
|
(pack) async {
|
||||||
return (await Future.wait(models.map(
|
final dto = await pack.toPreviewDto(userModel);
|
||||||
(model) async {
|
if (!pack.enabled) {
|
||||||
final dto =
|
|
||||||
await packDtoConverter.toCardPackPreviewDto(model, userModel);
|
|
||||||
if (!model.enabled) {
|
|
||||||
return dto.copyWith(subtitle: 'DISABLED ${dto.subtitle}');
|
return dto.copyWith(subtitle: 'DISABLED ${dto.subtitle}');
|
||||||
}
|
}
|
||||||
return dto;
|
return dto;
|
||||||
},
|
},
|
||||||
)))
|
)))
|
||||||
..sort((prev, next) {
|
.toList();
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//no images
|
Future<CardPack?> getPack(int id) async {
|
||||||
Future<List<CardPackAction>> listPacksActions(
|
return await _db.packDao.getPackById(id);
|
||||||
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<void> addPack(CardPackModel model, String productId) async {
|
Future<List<GameCard>> getCards(int packId) async {
|
||||||
// await isar.writeTxn(
|
return await _db.packDao.getPackCards(packId);
|
||||||
// () 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<bool> deleteCard(String idString) async {
|
Future<GameCard?> getCard(int id) async {
|
||||||
return isar.writeTxn(() async {
|
return await _db.packDao.getCardById(id);
|
||||||
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<void> addCard(GameCardDto dto) async {
|
Future<VoiceModel?> getVoice(int id) async {
|
||||||
await isar.writeTxn(() async {
|
// TODO: Implement in PackDao
|
||||||
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);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<CardPackBuyDto?> getPublicBuyPage(String packId) async {
|
Future<List<VoiceModel>> getVoices(int cardId) async {
|
||||||
final id = int.tryParse(packId);
|
// TODO: Implement in PackDao
|
||||||
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 {
|
|
||||||
return [];
|
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) {
|
Future<CardPackDto> getPackDto(int id, UserModel? userModel) async {
|
||||||
return Map.fromEntries(
|
final pack = await getPack(id);
|
||||||
cards.map((e) {
|
if (pack == null) {
|
||||||
final empty = MapEntry(e.id.toString(), <int>[]);
|
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 {
|
try {
|
||||||
final image = e.image.isEmpty
|
final image = await card.image.base64Image;
|
||||||
? empty
|
images.add(image);
|
||||||
: MapEntry(
|
|
||||||
e.id.toString(),
|
|
||||||
File('${assetsDirectory.path}/cards/${e.image}')
|
|
||||||
.readAsBytesSync(),
|
|
||||||
);
|
|
||||||
return image;
|
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
log('error while reading image', error: e, stackTrace: 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 {
|
static Directory get assetsDirectory {
|
||||||
String mainPath = Platform.resolvedExecutable;
|
String mainPath = Platform.resolvedExecutable;
|
||||||
if ((Platform.isMacOS || Platform.isLinux) &&
|
if ((Platform.isMacOS || Platform.isLinux) &&
|
||||||
|
|
@ -481,102 +157,4 @@ class PackManager {
|
||||||
return Directory('');
|
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:injectable/injectable.dart';
|
||||||
import 'package:isar/isar.dart';
|
|
||||||
import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
|
import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/extensions.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
import '../main.dart';
|
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class PromoCodesManager {
|
class PromoCodesManager {
|
||||||
|
final AppDatabase _db;
|
||||||
final PaymentManager _paymentManager;
|
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({
|
Future<List<PromoCodesCampaignDto>> promoCodeCampaigns({
|
||||||
List<String> withCodes = const [],
|
List<String> withCodes = const [],
|
||||||
}) async {
|
}) async {
|
||||||
final models = await isar
|
final campaigns = await _db.promoCodeDao.getActiveCampaigns();
|
||||||
.txn(() async => isar.promoCodesCampaignModels.where().findAll());
|
|
||||||
final dtos = await Future.wait(models.map(
|
final campaignDtos = <PromoCodesCampaignDto>[];
|
||||||
(m) async => await m.toDto(
|
for (final campaign in campaigns) {
|
||||||
withCodes: withCodes.contains(m.id.toString()),
|
List<String>? promoCodes;
|
||||||
),
|
if (withCodes.isNotEmpty) {
|
||||||
));
|
final codes = await _db.promoCodeDao.getPromoCodesByCampaignId(campaign.id);
|
||||||
return dtos;
|
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 {
|
Future<PromoCodesCampaignDto?> promoCodeCampaign(String id) async {
|
||||||
final intId = int.parse(id);
|
final campaignId = int.tryParse(id);
|
||||||
final model =
|
if (campaignId == null) return null;
|
||||||
await isar.txn(() async => isar.promoCodesCampaignModels.get(intId));
|
|
||||||
final dto = await model?.toDto(withCodes: true);
|
|
||||||
return dto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lists available promocodes for a user.
|
final campaign = await _db.promoCodeDao.getCampaignById(campaignId);
|
||||||
/// Returns active campaigns with promocodes that the user can apply.
|
if (campaign == null) return null;
|
||||||
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 codes = await _db.promoCodeDao.getPromoCodesByCampaignId(campaignId);
|
||||||
final activatedPromoCodes = userData.activatedPromoCodes;
|
final promoCodes = codes.map((code) => code.code).toList();
|
||||||
|
|
||||||
final now = DateTime.now();
|
return PromoCodesCampaignDto(
|
||||||
|
id: campaign.id,
|
||||||
// Get all active campaigns within date range
|
name: campaign.name,
|
||||||
// Campaign is active if: now >= start && now <= finish
|
template: campaign.template,
|
||||||
final activeCampaigns = await isar.txn(() async {
|
products: [], // TODO: convert from JSON
|
||||||
return await isar.promoCodesCampaignModels
|
activationsPerCode: campaign.activationsPerCode,
|
||||||
.filter()
|
activationsPerUser: campaign.activationsPerUser,
|
||||||
.statusEqualTo(PromoCodeCampaignModelStatus.active)
|
generationSize: campaign.generationSize,
|
||||||
.startLessThan(now, include: true)
|
start: campaign.start,
|
||||||
.finishGreaterThan(now, include: true)
|
finish: campaign.finish,
|
||||||
.findAll();
|
status: _parseCampaignStatus(campaign.status),
|
||||||
});
|
tags: campaign.tags,
|
||||||
|
promoCodes: promoCodes,
|
||||||
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 {
|
Future<void> createPromoCodeCampaign(PromoCodesCampaignDto dto) async {
|
||||||
PromoCodesCampaignModel model;
|
final companion = PromoCodesCampaignsCompanion.insert(
|
||||||
final id = dto.id;
|
template: dto.template,
|
||||||
if (id != null) {
|
name: drift.Value(dto.name),
|
||||||
final existingModel =
|
products: [], // TODO: convert from dto.products
|
||||||
await isar.txn(() => isar.promoCodesCampaignModels.get(id));
|
activationsPerCode: dto.activationsPerCode,
|
||||||
if (existingModel == null) {
|
activationsPerUser: dto.activationsPerUser,
|
||||||
log('Campaign with id: ${dto.id} not found');
|
generationSize: dto.generationSize,
|
||||||
return false;
|
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.
|
Future<dynamic> applyPromoCode(dynamic dto, dynamic user) async {
|
||||||
/// Returns a map with 'valid' (bool) and 'message' (String) keys.
|
final code = dto['code']?.toString()?.toUpperCase();
|
||||||
Future<Map<String, dynamic>> validatePromocode(
|
if (code == null) {
|
||||||
String code,
|
throw ArgumentError('Promo code is required');
|
||||||
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': 'Ошибка при проверке промокода',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
Future<bool> launchPromoCodesCampaign(dynamic campaign) async {
|
||||||
try {
|
// TODO: Generate promo codes for campaign
|
||||||
PromoCodeModel? checkedCodeModel;
|
// This involves generating codes based on template and generationSize
|
||||||
final checkResult = await isar.writeTxn(() async {
|
return false;
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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: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/mnemo_cards_common.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.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
|
@lazySingleton
|
||||||
class AchievementManager {
|
class AchievementManager {
|
||||||
final Isar _isar;
|
final AppDatabase _db;
|
||||||
|
|
||||||
AchievementManager(this._isar);
|
AchievementManager(this._db);
|
||||||
|
|
||||||
/// Get all available achievements with their definitions
|
/// Get all available achievements with their definitions
|
||||||
List<AchievementDefinition> get allAchievementDefinitions =>
|
List<AchievementDto> get allAchievementDefinitions => AchievementDefinitions.allAchievements;
|
||||||
_achievementDefinitions;
|
|
||||||
|
|
||||||
/// Check and update achievements for a user
|
/// 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(
|
Future<List<AchievementDto>> checkAndUnlockAchievements(
|
||||||
int userId,
|
int userId,
|
||||||
UserDataModel userData,
|
UserDataModel userData,
|
||||||
) async {
|
) async {
|
||||||
final newlyUnlocked = <AchievementDto>[];
|
final unlockedAchievements = <AchievementDto>[];
|
||||||
|
|
||||||
try {
|
// Get existing user achievements
|
||||||
// Get current user achievements
|
final userAchievements = await getUserAchievements(userId);
|
||||||
final currentAchievements = await _getUserAchievements(userId);
|
final existingIds = userAchievements.map((a) => a.id).toSet();
|
||||||
|
|
||||||
for (final definition in _achievementDefinitions) {
|
// Check each achievement
|
||||||
// Skip if already unlocked
|
for (final achievement in AchievementDefinitions.allAchievements) {
|
||||||
if (currentAchievements
|
if (existingIds.contains(achievement.id)) continue;
|
||||||
.any((a) => a.id == definition.id && a.isUnlocked)) {
|
|
||||||
continue;
|
final isUnlocked = await _checkAchievementCondition(userId, userData, achievement);
|
||||||
|
if (isUnlocked) {
|
||||||
|
final unlocked = await unlockAchievement(userId, achievement.id);
|
||||||
|
if (unlocked != null) {
|
||||||
|
unlockedAchievements.add(unlocked);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// Check if achievement should be unlocked
|
// Update progress if applicable
|
||||||
final shouldUnlock = await _evaluateAchievement(definition, userData);
|
final progress = await _calculateAchievementProgress(userId, userData, achievement);
|
||||||
|
if (progress > 0.0) {
|
||||||
if (shouldUnlock) {
|
await _db.achievementDao.updateAchievementProgress(userId, achievement.id, progress);
|
||||||
final unlockedAchievement = definition.unlock();
|
|
||||||
newlyUnlocked.add(unlockedAchievement);
|
|
||||||
|
|
||||||
// Save to database
|
|
||||||
await _saveAchievement(userId, unlockedAchievement);
|
|
||||||
|
|
||||||
log('Achievement unlocked: ${unlockedAchievement.title} for user $userId');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} 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 {
|
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)
|
/// Check if user has specific achievement
|
||||||
Future<bool> forceUnlockAchievement(
|
Future<bool> hasAchievement(int userId, String achievementId) async {
|
||||||
|
return await _db.achievementDao.hasAchievement(userId, achievementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unlock achievement for user
|
||||||
|
Future<AchievementDto?> unlockAchievement(
|
||||||
int userId,
|
int userId,
|
||||||
String achievementId,
|
String achievementId,
|
||||||
) async {
|
) async {
|
||||||
final definition = _achievementDefinitions.firstWhere(
|
final definition = AchievementDefinitions.getById(achievementId);
|
||||||
(def) => def.id == achievementId,
|
if (definition == null) return null;
|
||||||
);
|
|
||||||
|
|
||||||
final unlockedAchievement = definition.unlock();
|
final companion = UserAchievementsCompanion.insert(
|
||||||
await _saveAchievement(userId, unlockedAchievement);
|
userId: userId,
|
||||||
|
achievementId: achievementId,
|
||||||
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,
|
|
||||||
unlockedAt: DateTime.now(),
|
unlockedAt: DateTime.now(),
|
||||||
progress: 1.0,
|
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
|
/// Get achievement progress for user
|
||||||
AchievementDto withProgress(double progress) {
|
Future<Map<String, double>> getAchievementProgress(int userId) async {
|
||||||
return AchievementDto(
|
return await _db.achievementDao.getAchievementProgress(userId);
|
||||||
id: id,
|
|
||||||
title: title,
|
|
||||||
description: description,
|
|
||||||
type: type,
|
|
||||||
progress: progress.clamp(0.0, 1.0),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/// 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:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package: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
|
@lazySingleton
|
||||||
class SessionTracker {
|
class SessionTracker {
|
||||||
final Isar _isar;
|
final AppDatabase _db;
|
||||||
|
|
||||||
/// Active sessions cache: userId -> sessionId
|
/// Active sessions cache: userId -> sessionId
|
||||||
final Map<int, String> _activeSessions = {};
|
final Map<int, String> _activeSessions = {};
|
||||||
|
|
@ -22,7 +19,7 @@ class SessionTracker {
|
||||||
/// Session timeout duration (30 minutes by default)
|
/// Session timeout duration (30 minutes by default)
|
||||||
static const Duration _sessionTimeout = Duration(minutes: 30);
|
static const Duration _sessionTimeout = Duration(minutes: 30);
|
||||||
|
|
||||||
SessionTracker(this._isar);
|
SessionTracker(this._db);
|
||||||
|
|
||||||
/// Get or create an active session for a user
|
/// Get or create an active session for a user
|
||||||
///
|
///
|
||||||
|
|
@ -48,237 +45,148 @@ class SessionTracker {
|
||||||
|
|
||||||
// Create new session
|
// Create new session
|
||||||
final sessionId = _generateSessionId();
|
final sessionId = _generateSessionId();
|
||||||
final session = StudySessionModel(
|
final now = DateTime.now();
|
||||||
sessionId: sessionId,
|
|
||||||
userId: userId,
|
await _db.statisticsDao.createSession(
|
||||||
startTime: DateTime.now(),
|
StudySessionsCompanion.insert(
|
||||||
packId: packId,
|
userId: userId,
|
||||||
testId: testId,
|
sessionId: drift.Value(sessionId),
|
||||||
|
startTime: now,
|
||||||
|
packId: drift.Value(packId),
|
||||||
|
testId: drift.Value(testId),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save to database
|
// Cache active session
|
||||||
await _isar.writeTxn(() async {
|
|
||||||
await _isar.studySessionModels.put(session);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track as active
|
|
||||||
_activeSessions[userId] = sessionId;
|
_activeSessions[userId] = sessionId;
|
||||||
_resetSessionTimer(sessionId);
|
|
||||||
|
// Start timeout timer
|
||||||
|
_startSessionTimer(sessionId);
|
||||||
|
|
||||||
return sessionId;
|
return sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update session with progress data
|
/// End a session manually
|
||||||
///
|
|
||||||
/// 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.
|
|
||||||
Future<void> endSession(
|
Future<void> endSession(
|
||||||
String sessionId, {
|
String sessionId, {
|
||||||
int wordsLearned = 0,
|
int? wordsLearned,
|
||||||
int testsCompleted = 0,
|
int? testsCompleted,
|
||||||
double accuracy = 0.0,
|
double? accuracy,
|
||||||
}) async {
|
}) async {
|
||||||
final session = await _isar.studySessionModels
|
// Cancel timer
|
||||||
.filter()
|
_sessionTimers[sessionId]?.cancel();
|
||||||
.sessionIdEqualTo(sessionId)
|
_sessionTimers.remove(sessionId);
|
||||||
.findFirst();
|
|
||||||
|
|
||||||
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
|
if (userId != null) {
|
||||||
final endedSession = session.end(
|
_activeSessions.remove(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session in database
|
||||||
|
await _db.statisticsDao.endSession(
|
||||||
|
sessionId: sessionId,
|
||||||
wordsLearned: wordsLearned,
|
wordsLearned: wordsLearned,
|
||||||
testsCompleted: testsCompleted,
|
testsCompleted: testsCompleted,
|
||||||
accuracy: accuracy,
|
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
|
/// Update session statistics
|
||||||
Future<void> endUserSession(int userId) async {
|
Future<void> updateSessionStats(
|
||||||
final sessionId = _activeSessions[userId];
|
String sessionId, {
|
||||||
if (sessionId != null) {
|
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);
|
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
|
/// Generate a unique session ID
|
||||||
String _generateSessionId() {
|
String _generateSessionId() {
|
||||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
final random = Random();
|
||||||
final random = DateTime.now().microsecondsSinceEpoch % 10000;
|
final bytes = List<int>.generate(16, (i) => random.nextInt(256));
|
||||||
return 'session_${timestamp}_$random';
|
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset the timeout timer for a session
|
/// Start timeout timer for session
|
||||||
void _resetSessionTimer(String sessionId) {
|
void _startSessionTimer(String sessionId) {
|
||||||
_cancelSessionTimer(sessionId);
|
_sessionTimers[sessionId] = Timer(_sessionTimeout, () async {
|
||||||
|
await endSession(sessionId);
|
||||||
_sessionTimers[sessionId] = Timer(_sessionTimeout, () {
|
|
||||||
// Session timed out - end it
|
|
||||||
endSession(sessionId);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cancel the timeout timer for a session
|
/// Reset timeout timer for session
|
||||||
void _cancelSessionTimer(String sessionId) {
|
void _resetSessionTimer(String sessionId) {
|
||||||
final timer = _sessionTimers.remove(sessionId);
|
_sessionTimers[sessionId]?.cancel();
|
||||||
timer?.cancel();
|
_startSessionTimer(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update session last activity time
|
/// Update session activity timestamp
|
||||||
Future<void> _updateSessionActivity(String sessionId) async {
|
Future<void> _updateSessionActivity(String sessionId) async {
|
||||||
// For now, we just reset the timer
|
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
|
||||||
// In the future, we could track last activity timestamps
|
if (session != null) {
|
||||||
}
|
await _db.statisticsDao.updateSession(
|
||||||
|
session.copyWith(updatedAt: DateTime.now()),
|
||||||
/// Dispose of all timers (for cleanup)
|
);
|
||||||
void dispose() {
|
|
||||||
for (final timer in _sessionTimers.values) {
|
|
||||||
timer.cancel();
|
|
||||||
}
|
}
|
||||||
_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 'dart:io';
|
||||||
|
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:mnemo_cards_backend/database/database.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
|
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/tests/generators/models/creation_test_data.dart';
|
import 'package:mnemo_cards_backend/tests/generators/models/creation_test_data.dart';
|
||||||
import 'package:mnemo_cards_backend/tests/test_extension.dart';
|
import 'package:mnemo_cards_backend/tests/test_extension.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
import 'package:drift/drift.dart' as drift;
|
||||||
|
|
||||||
import '../main.dart';
|
|
||||||
import '../packs/pack_dto_converter.dart';
|
import '../packs/pack_dto_converter.dart';
|
||||||
import 'generators/question_generators/input_buttons_question_generator.dart';
|
import 'generators/question_generators/input_buttons_question_generator.dart';
|
||||||
import 'generators/pack_test_generator.dart';
|
import 'generators/pack_test_generator.dart';
|
||||||
|
|
@ -17,214 +17,228 @@ import 'generators/question_generators/simple_question_generator.dart';
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class TestManager {
|
class TestManager {
|
||||||
|
final AppDatabase _db;
|
||||||
final PackDtoConverter _packDtoConverter;
|
final PackDtoConverter _packDtoConverter;
|
||||||
List<CreationTestData> _customCreationTestData = [];
|
List<CreationTestData> _customCreationTestData = [];
|
||||||
|
|
||||||
TestManager(this._packDtoConverter);
|
TestManager(this._db, this._packDtoConverter);
|
||||||
|
|
||||||
Future<TestStatisticsDto?> _testStatisticsDto(
|
Future<TestStatisticsDto?> _testStatisticsDto(
|
||||||
UserModel user, int testId) async {
|
int userId, int testId) async {
|
||||||
return (await user.userData.value?.testsStatistics
|
final statistics = await _db.testDao.getTestStatistics(userId, testId);
|
||||||
.filter()
|
if (statistics == null) return null;
|
||||||
.test((q) => q.idEqualTo(testId))
|
|
||||||
.findFirst())
|
// Convert TestStatistic to TestStatisticsDto
|
||||||
?.toDto();
|
return TestStatisticsDto(
|
||||||
|
testId: statistics.testId.toString(),
|
||||||
|
results: statistics.results,
|
||||||
|
completedAt: statistics.completedAt.toIso8601String(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<TestDto?> fetchTest(String id, UserModel user) async {
|
Future<TestDto?> fetchTest(String id, UserModel user) async {
|
||||||
return (await isar.testModels.get(int.parse(id)))?.toDto(
|
final testId = int.tryParse(id);
|
||||||
statistics: await _testStatisticsDto(user, int.parse(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 {
|
Future<List<TestDto>> availableTests(UserModel userModel) async {
|
||||||
final tests = (await isar.testModels.where().findAll());
|
final tests = await _db.testDao.getAllTests();
|
||||||
final dtos = await Future.wait(tests.map(
|
|
||||||
(test) async => test.toDto(
|
final testDtos = <TestDto>[];
|
||||||
questions: [],
|
for (final test in tests) {
|
||||||
statistics: await _testStatisticsDto(userModel, test.id!),
|
final questions = await _db.testDao.getTestQuestions(test.id);
|
||||||
),
|
final statistics = await _testStatisticsDto(userModel.id!, test.id);
|
||||||
));
|
|
||||||
return dtos.toList();
|
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(
|
Future<List<TestDto>> fetchPackTests(
|
||||||
UserModel user,
|
UserModel user,
|
||||||
CardPackModel model,
|
CardPackModel model,
|
||||||
) async {
|
) async {
|
||||||
final pack = (await isar.cardPackModels.get(model.id!))!;
|
final packId = model.id;
|
||||||
final Iterable<TestModel> testModels;
|
if (packId == null) return [];
|
||||||
if (await _updateGeneratedTestsIfRequired(pack)) {
|
|
||||||
testModels = (await isar.cardPackModels.get(model.id!))!.tests;
|
final tests = await _db.testDao.getTestsByPackId(packId);
|
||||||
} else {
|
|
||||||
testModels = pack.tests;
|
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(
|
return testDtos;
|
||||||
(e) async => e.toDto(
|
|
||||||
statistics: await _testStatisticsDto(user, e.id!),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _updateGeneratedTestsIfRequired(CardPackModel model) async {
|
Future<bool> _updateGeneratedTestsIfRequired(CardPackModel model) async {
|
||||||
final generated = model.tests.where((t) => t.version == 'generated');
|
final packId = model.id;
|
||||||
if (generated.isEmpty) {
|
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);
|
await updateGeneratedTests(model);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refreshCustomCreationTestsData() async {
|
Future<void> refreshCustomCreationTestsData() async {
|
||||||
final dir = Directory('${PackManager.assetsDirectory.path}/tests/');
|
// Load custom creation test data from database
|
||||||
List<CreationTestData> data = [];
|
// For now, keep it simple
|
||||||
if (await dir.exists()) {
|
_customCreationTestData = [];
|
||||||
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 {
|
Future<void> updateGeneratedTests(CardPackModel model) async {
|
||||||
final oldGenerated =
|
final packId = model.id;
|
||||||
model.tests.where((t) => t.version == 'generated').toList();
|
if (packId == null) return;
|
||||||
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 {
|
// Get pack data from database
|
||||||
for (final test in testDtos) {
|
final pack = await _db.packDao.getPackById(packId);
|
||||||
final testModel = test.toEmptyModel();
|
if (pack == null) return;
|
||||||
final questionModels = test.questions
|
|
||||||
.map(
|
// Get all cards for the pack
|
||||||
(e) => TestQuestionModel(
|
final cards = await _db.packDao.getPackCards(packId);
|
||||||
questionType: e.questionType,
|
if (cards.isEmpty) return;
|
||||||
body: jsonEncode(e.toJson()),
|
|
||||||
),
|
// Create creation test data
|
||||||
)
|
final testDataItems = cards.map((card) {
|
||||||
.toList();
|
return TestDataItem(
|
||||||
await isar.testQuestionModels.putAll(questionModels);
|
id: card.id.toString(),
|
||||||
testModel.questions.addAll(questionModels);
|
original: card.original,
|
||||||
testModel.packs.add(model);
|
translation: card.translation,
|
||||||
await isar.testModels.put(testModel);
|
mnemo: card.mnemo,
|
||||||
await testModel.packs.save();
|
image: card.image,
|
||||||
await testModel.questions.save();
|
back: card.back,
|
||||||
}
|
transcription: card.transcription,
|
||||||
if (oldGenerated.isNotEmpty) {
|
);
|
||||||
for (final test in oldGenerated) {
|
}).toList();
|
||||||
await test.questions.load();
|
|
||||||
final questions =
|
final creationTestData = CreationTestData(
|
||||||
test.questions.map((e) => e.id).whereNotNull().toList();
|
items: testDataItems,
|
||||||
if (questions.isNotEmpty) {
|
color: pack.color,
|
||||||
await isar.testQuestionModels.deleteAll(questions);
|
packId: packId.toString(),
|
||||||
}
|
);
|
||||||
await isar.testModels.delete(test.id!);
|
|
||||||
}
|
// 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 {
|
Future<void> addTest(TestDto testDto) async {
|
||||||
await isar.writeTxn(() async {
|
await _db.transaction(() async {
|
||||||
final questions = <TestQuestionModel>[];
|
// Create test
|
||||||
for (final q in testDto.questions.where((q) => q.body != null)) {
|
final testCompanion = TestsCompanion.insert(
|
||||||
final model = TestQuestionModel(
|
name: testDto.name,
|
||||||
id: q.id,
|
color: drift.Value(testDto.color),
|
||||||
questionType: q.questionType,
|
cover: drift.Value(testDto.cover),
|
||||||
body: q.body!,
|
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 'dart:developer';
|
||||||
|
|
||||||
import 'package:injectable/injectable.dart';
|
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_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.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/statistics_calculator.dart';
|
||||||
import '../statistics/achievement_manager.dart';
|
import '../statistics/achievement_manager.dart';
|
||||||
import 'secure.dart';
|
import 'secure.dart';
|
||||||
|
import 'user_drift_extension.dart';
|
||||||
|
|
||||||
Map<Id?, DateTime> _onlineUsers = {};
|
Map<int?, DateTime> _onlineUsers = {};
|
||||||
|
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class UserManager {
|
class UserManager {
|
||||||
|
final AppDatabase _db;
|
||||||
final FreePacksDistributor _freePacksDistributor;
|
final FreePacksDistributor _freePacksDistributor;
|
||||||
final SessionTracker _sessionTracker;
|
// TODO: Re-enable when migrated to PostgreSQL
|
||||||
final StatisticsCalculator _statisticsCalculator;
|
// final SessionTracker _sessionTracker;
|
||||||
final AchievementManager _achievementManager;
|
// final StatisticsCalculator _statisticsCalculator;
|
||||||
|
// final AchievementManager _achievementManager;
|
||||||
|
|
||||||
UserManager(
|
UserManager(
|
||||||
|
this._db,
|
||||||
this._freePacksDistributor,
|
this._freePacksDistributor,
|
||||||
this._sessionTracker,
|
// this._sessionTracker,
|
||||||
this._statisticsCalculator,
|
// this._statisticsCalculator,
|
||||||
this._achievementManager,
|
// this._achievementManager,
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<UserModel?> fetchUser(Id id) async {
|
Future<UserModel?> fetchUser(int id) async {
|
||||||
final user = await isar.userModels.get(id);
|
final user = await _db.userDao.getUserById(id);
|
||||||
return user;
|
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 {
|
Future<String> createOrGetAuthToken(UserModel user, String externalId) async {
|
||||||
if (user.id == null) {
|
if (user.id == null) {
|
||||||
throw Exception('Cant create token for empty id');
|
throw Exception('Cant create token for empty id');
|
||||||
}
|
}
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final tokenModel =
|
final token = await _db.userDao.getTokenByUserId(user.id!);
|
||||||
await isar.tokenModels.filter().userIdEqualTo(user.id!).findFirst();
|
if (token != null) {
|
||||||
if (tokenModel != null) {
|
if (token.expires.isAfter(now)) {
|
||||||
if (tokenModel.expires.isAfter(now)) {
|
return token.token;
|
||||||
return tokenModel.token;
|
|
||||||
}
|
}
|
||||||
await isar.tokenModels.delete(tokenModel.id!);
|
await _db.userDao.deleteToken(token.id);
|
||||||
}
|
}
|
||||||
final userToken = Secure.token();
|
final userToken = Secure.token();
|
||||||
await isar.tokenModels.put(
|
await _db.userDao.createToken(
|
||||||
TokenModel(
|
TokensCompanion.insert(
|
||||||
token: userToken,
|
token: userToken,
|
||||||
externalUserId: externalId,
|
externalUserId: externalId,
|
||||||
userId: user.id!,
|
userId: user.id!,
|
||||||
created: now,
|
expires: now.add(const Duration(days: 360)),
|
||||||
expires: now.add(Duration(days: 360)),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return userToken;
|
return userToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<UserModel?> getUserByToken(String authToken) async {
|
Future<UserModel?> getUserByToken(String authToken) async {
|
||||||
final tokenModel =
|
final token = await _db.userDao.getTokenByValue(authToken);
|
||||||
await isar.tokenModels.filter().tokenEqualTo(authToken).findFirst();
|
if (token == null) {
|
||||||
if (tokenModel == null) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (tokenModel.expires.isBefore(now)) {
|
if (token.expires.isBefore(now)) {
|
||||||
isar.writeTxn(() => isar.tokenModels.delete(tokenModel.id!));
|
await _db.userDao.deleteToken(token.id);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final user = await isar.userModels.get(tokenModel.userId);
|
final user = await fetchUser(token.userId);
|
||||||
if (user == null) {
|
if (user != null) {
|
||||||
return null;
|
_onlineUsers[token.userId] = now;
|
||||||
} else {
|
|
||||||
_onlineUsers[user.id] = now;
|
|
||||||
if (_onlineUsers.length > 300) {
|
if (_onlineUsers.length > 300) {
|
||||||
updateOnlineUsers();
|
updateOnlineUsers();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,28 +89,23 @@ class UserManager {
|
||||||
if (_onlineUsers.isEmpty) {
|
if (_onlineUsers.isEmpty) {
|
||||||
return;
|
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 userIds = _onlineUsers.keys.whereNotNull().toList();
|
||||||
final id = int.tryParse(userId);
|
|
||||||
if (id == null) return [];
|
for (final userId in userIds) {
|
||||||
return isar.txn(
|
final lastOnline = _onlineUsers[userId];
|
||||||
() => isar.tokenModels.filter().userIdEqualTo(id).findAll(),
|
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({
|
Future<(UserModel, String)> createOrGetUser({
|
||||||
|
|
@ -118,369 +113,49 @@ class UserManager {
|
||||||
required String email,
|
required String email,
|
||||||
String? name,
|
String? name,
|
||||||
}) async {
|
}) async {
|
||||||
final tokenModel = await isar.tokenModels
|
// Check if user exists by externalId
|
||||||
.filter()
|
final existingUser = await _db.userDao.getUserByExternalId(externalId);
|
||||||
.externalUserIdEqualTo(externalId)
|
if (existingUser != null) {
|
||||||
.findFirst();
|
print('User found $name $email');
|
||||||
var user = tokenModel == null ? null : await fetchUser(tokenModel.userId);
|
final userModel = await existingUser.toUserModel();
|
||||||
if (user == null) {
|
final token = await createOrGetAuthToken(userModel, externalId);
|
||||||
print('Creating new user $name $email');
|
return (userModel, token);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Future<void> addWordStatistics(
|
print('Creating new user $name $email');
|
||||||
// UserModel user, AllWordsStatisticsDto wordStat) async {
|
|
||||||
// // final currentData =
|
|
||||||
// // user.userData?.decode(UserDataDto.fromJson) ?? UserDataDto();
|
|
||||||
// // updateUserData(
|
|
||||||
// // user,
|
|
||||||
// // currentData.copyWith.allWordsStatistics(
|
|
||||||
// // wordStat.merge(currentData.allWordsStatistics),
|
|
||||||
// // ),
|
|
||||||
// // );
|
|
||||||
// }
|
|
||||||
|
|
||||||
Future<void> addTestStatistics(
|
// Create new user
|
||||||
UserModel user,
|
final userCompanion = UsersCompanion.insert(
|
||||||
TestStatisticsDto testStat,
|
externalUserId: externalId,
|
||||||
) async {
|
name: drift.Value(name),
|
||||||
UserDataModel currentData;
|
email: drift.Value(email),
|
||||||
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(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
print('got tests stat for ${testStat.testId}');
|
final userId = await _db.userDao.createUser(userCompanion);
|
||||||
|
|
||||||
final allWords = currentData.words.mergeWithDto(testStat.words.words);
|
// Create user data
|
||||||
print(
|
await _db.userDao.createUserData(
|
||||||
'merged all words: ${allWords.length} (was ${currentData.words.length})');
|
UserDatasCompanion.insert(
|
||||||
|
userId: userId,
|
||||||
return isar.writeTxn(() async {
|
registrationDate: drift.Value(DateTime.now()),
|
||||||
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()),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 {
|
// TODO: Implement remaining methods as needed
|
||||||
if (user.id == null || user.id! < 0) {
|
// updateUserSettings, addTestStatistics, editUser, deleteUser
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Operation completed!"
|
description: "Operation completed!"
|
||||||
/ads/product/acquire/<key>:
|
/cards:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- AdminCardsApiV2
|
||||||
|
summary: getAllCards
|
||||||
|
operationId: getAllCards
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: "Operation completed!"
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- AdsApiV2
|
- AdminCardsApiV2
|
||||||
summary: "POST /api/v2/ads/product/acquire/{key}"
|
summary: createCard
|
||||||
description: Confirms rewarded ad completion and grants product access to the user.
|
operationId: createCard
|
||||||
operationId: acquireProductForAd
|
responses:
|
||||||
|
200:
|
||||||
|
description: "Operation completed!"
|
||||||
|
/cards/<cardId>:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- AdminCardsApiV2
|
||||||
|
summary: getCard
|
||||||
|
operationId: getCard
|
||||||
parameters:
|
parameters:
|
||||||
- name: key
|
- name: cardId
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
|
|
@ -145,42 +161,11 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Operation completed!"
|
description: "Operation completed!"
|
||||||
/adsgram/reward:
|
put:
|
||||||
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:
|
|
||||||
tags:
|
tags:
|
||||||
- AdminCardsApiV2
|
- AdminCardsApiV2
|
||||||
summary: getCards
|
summary: updateCard
|
||||||
description: "GET /api/v2/admin/cards\nGet all cards with pagination and optional search\nQuery params: ?page=1&limit=20&search=term"
|
operationId: updateCard
|
||||||
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
|
|
||||||
parameters:
|
parameters:
|
||||||
- name: cardId
|
- name: cardId
|
||||||
in: path
|
in: path
|
||||||
|
|
@ -194,7 +179,6 @@ paths:
|
||||||
tags:
|
tags:
|
||||||
- AdminCardsApiV2
|
- AdminCardsApiV2
|
||||||
summary: deleteCard
|
summary: deleteCard
|
||||||
description: "DELETE /api/v2/admin/cards/:id\nDelete a card by ID"
|
|
||||||
operationId: deleteCard
|
operationId: deleteCard
|
||||||
parameters:
|
parameters:
|
||||||
- name: cardId
|
- name: cardId
|
||||||
|
|
@ -354,275 +338,6 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Operation completed!"
|
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:
|
/admin/auth/request-code:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -669,64 +384,6 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Operation completed!"
|
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:
|
/auth/oauth/google:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -880,6 +537,56 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Operation completed!"
|
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:
|
/admin/analytics/dashboard:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -969,7 +676,7 @@ paths:
|
||||||
tags:
|
tags:
|
||||||
- TasksApiV2
|
- TasksApiV2
|
||||||
summary: "GET /api/v2/users/me/tasks/progress - Get user task progress"
|
summary: "GET /api/v2/users/me/tasks/progress - Get user task progress"
|
||||||
operationId: getUserProgress
|
operationId: getUserTaskProgress
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Operation completed!"
|
description: "Operation completed!"
|
||||||
|
|
@ -977,94 +684,31 @@ paths:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- TasksApiV2
|
- TasksApiV2
|
||||||
summary: "GET /api/v2/tasks/categories - Get available task categories and filters"
|
summary: "GET /api/v2/tasks/categories - Get task categories"
|
||||||
operationId: getTaskCategories
|
operationId: getTaskCategories
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Operation completed!"
|
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: { }
|
components: { }
|
||||||
tags:
|
tags:
|
||||||
- name: PromocodesApiV2
|
- name: PromocodesApiV2
|
||||||
description: API v2 endpoints for promocode management and activation.
|
description: API v2 endpoints for promocode management and activation.
|
||||||
- name: DiscountsApiV2
|
- name: DiscountsApiV2
|
||||||
description: Admin endpoints for discount campaign management.
|
description: Admin endpoints for discount campaign management.
|
||||||
- name: AdsApiV2
|
|
||||||
description: API v2 endpoints for rewarded ads flows.
|
|
||||||
- name: AdminCardsApiV2
|
- name: AdminCardsApiV2
|
||||||
description: Admin endpoints for card management in API v2.
|
|
||||||
- name: SubscriptionsApiV2
|
- name: SubscriptionsApiV2
|
||||||
description: "Subscriptions API v2\nRESTful endpoints for managing subscriptions & plans"
|
description: "Subscriptions API v2\nRESTful endpoints for managing subscriptions & plans"
|
||||||
- name: UsersApiV2
|
- name: UsersApiV2
|
||||||
description: "API v2 endpoints for user profile and self-service operations."
|
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
|
- name: AdminAuthApiV2
|
||||||
description: Admin authentication API endpoints
|
description: Admin authentication API endpoints
|
||||||
- name: PurchasesApiV2
|
|
||||||
description: Purchases API v2\n\nRESTful endpoints for managing purchases and payments
|
|
||||||
- name: AuthApiV2
|
- name: AuthApiV2
|
||||||
description: API v2 Authentication endpoints\n\nImplements OAuth2/JWT Bearer token authentication
|
description: API v2 Authentication endpoints\n\nImplements OAuth2/JWT Bearer token authentication
|
||||||
- name: TestsApiV2
|
- name: TestsApiV2
|
||||||
description: Tests API v2\n\nRESTful endpoints for managing tests and test results
|
description: Tests API v2\n\nRESTful endpoints for managing tests and test results
|
||||||
|
- name: TelegramBotApiV2
|
||||||
|
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
|
- name: AdminAnalyticsApiV2
|
||||||
description: Admin endpoints for analytics and statistics in API v2.
|
description: Admin endpoints for analytics and statistics in API v2.
|
||||||
- name: TasksApiV2
|
- name: TasksApiV2
|
||||||
description: API v2 endpoints for user tasks management
|
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"
|
|
||||||
|
|
@ -25,6 +25,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.13.0"
|
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:
|
archive:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
@ -81,6 +89,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.1"
|
version: "2.1.1"
|
||||||
|
buffer:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: buffer
|
||||||
|
sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.3"
|
||||||
build:
|
build:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -145,14 +161,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.9.2"
|
version: "8.9.2"
|
||||||
characters:
|
charcode:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: characters
|
name: charcode
|
||||||
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
|
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.4.0"
|
||||||
checked_yaml:
|
checked_yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -169,6 +185,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.0"
|
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:
|
clock:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -241,14 +265,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
dartx:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: dartx
|
|
||||||
sha256: "8b25435617027257d43e6508b5fe061012880ddfdaa75a71d607c3de2a13d244"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.2.0"
|
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -265,6 +281,30 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
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:
|
encrypt:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -426,21 +466,13 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.4"
|
version: "1.0.4"
|
||||||
isar:
|
isar:
|
||||||
dependency: "direct main"
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: isar
|
name: isar
|
||||||
sha256: "99165dadb2cf2329d3140198363a7e7bff9bbd441871898a87e26914d25cf1ea"
|
sha256: "99165dadb2cf2329d3140198363a7e7bff9bbd441871898a87e26914d25cf1ea"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.0+1"
|
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:
|
jaguar_jwt:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -591,6 +623,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.1"
|
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:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -631,6 +671,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.2"
|
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:
|
shelf:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -767,6 +823,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.0"
|
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:
|
stack_trace:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -831,14 +903,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.5"
|
version: "0.6.5"
|
||||||
time:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: time
|
|
||||||
sha256: ad8e018a6c9db36cb917a031853a1aae49467a93e0d464683e029537d848c221
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.4"
|
|
||||||
timing:
|
timing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -855,14 +919,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.2"
|
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:
|
uuid:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: uuid
|
name: uuid
|
||||||
sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313"
|
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "4.5.2"
|
||||||
version:
|
version:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -927,14 +999,6 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.5.0"
|
version: "6.5.0"
|
||||||
xxh3:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: xxh3
|
|
||||||
sha256: a92b30944a9aeb4e3d4f3c3d4ddb3c7816ca73475cd603682c4f8149690f56d7
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.1"
|
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -947,9 +1011,9 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: yookassa_client
|
name: yookassa_client
|
||||||
sha256: "667d04d0e2d8c7e5180d26a3966587cc99d38fb887d318e36562a202d1adb6e2"
|
sha256: e801e1bb22f21f883adbee15645e2c9b21c4a640f8e096006a6295c335c588aa
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.2"
|
version: "1.0.5"
|
||||||
sdks:
|
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.
|
# 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
|
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
|
|
||||||
version: 1.0.0+4
|
version: 1.0.0+5
|
||||||
isar_version: &isar_version 3.1.0+1 # define the version to be used
|
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|
@ -19,12 +18,16 @@ dependencies:
|
||||||
mnemo_cards_common_backend:
|
mnemo_cards_common_backend:
|
||||||
path: ../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
|
image: ^4.1.7
|
||||||
http: ^1.1.0
|
http: ^1.1.0
|
||||||
|
|
||||||
# shared_preferences: ^2.2.2
|
# shared_preferences: ^2.2.2
|
||||||
async:
|
async:
|
||||||
isar: *isar_version
|
|
||||||
# path_provider: ^2.1.1
|
# path_provider: ^2.1.1
|
||||||
get_it: ^7.6.4
|
get_it: ^7.6.4
|
||||||
injectable: ^2.4.1
|
injectable: ^2.4.1
|
||||||
|
|
@ -46,7 +49,7 @@ dependencies:
|
||||||
crypto: ^3.0.3
|
crypto: ^3.0.3
|
||||||
googleapis: ^13.1.0
|
googleapis: ^13.1.0
|
||||||
googleapis_auth:
|
googleapis_auth:
|
||||||
uuid: ^3.0.7
|
uuid: ^4.5.2
|
||||||
|
|
||||||
yookassa_client: ^1.0.2
|
yookassa_client: ^1.0.2
|
||||||
neat_periodic_task: ^2.0.1
|
neat_periodic_task: ^2.0.1
|
||||||
|
|
@ -54,8 +57,11 @@ dependencies:
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
build_runner:
|
build_runner: ^2.4.0
|
||||||
isar_generator: *isar_version
|
|
||||||
|
# Drift code generation
|
||||||
|
drift_dev: ^2.14.0
|
||||||
|
|
||||||
shelf_router_generator: ^1.1.0
|
shelf_router_generator: ^1.1.0
|
||||||
shelf_open_api_generator:
|
shelf_open_api_generator:
|
||||||
injectable_generator:
|
injectable_generator:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue