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

This commit is contained in:
Dmitry 2025-12-13 16:27:05 +03:00
parent 170a786ca2
commit 77b4ce91b5
89 changed files with 20674 additions and 4308 deletions

View file

@ -6,5 +6,6 @@ When rule is mentioned:
1. Run mnemo_cards_web/complete_auto_debug.sh
2. Wait for it to complete
3. Read and analyse the report in mnemo_cards_web/debug_report
4. Fix the errors
5. Go to step 1
4. Never leave TODOS
5. Fix the errors
6. Go to step 1

View file

@ -4,7 +4,6 @@ alwaysApply: true
Use yx_state and yx_scope,
Follow clean architecture techique,
Modularize and split into files,
You can use ../mnemo_cards app as a design and product features reference, but NEVER copy its architecture.
When you finish with another step:
Write unit tests for all the functionalities
Update PROGESS.md and TODO.md

13
.gitignore vendored
View file

@ -52,4 +52,15 @@ app.*.map.json
*.isar
mnemo_cards_backend/backup
mnemo_cards_backend/build
mnemo_cards_web_v2/build
mnemo_cards_web_v2/build
# Environment variables
.env
.env.local
.env.production
# PostgreSQL data (если локально запускаете вне Docker)
postgres_data/
# Старые Isar файлы (можно удалить после миграции)
mnemo_cards_backend/isar/

View file

@ -15,10 +15,12 @@ class ResourceLoader {
if (_packModelCacheId == packId && _packModelCache != null) {
return _packModelCache;
}
final model = await backend_main.isar.cardPackModels.get(packId);
_packModelCache = model;
final pack = await packManager.getPack(packId);
// TODO: Convert CardPack to CardPackModel if needed
// For now return null to avoid breaking
_packModelCache = null; // pack?.toCardPackModel();
_packModelCacheId = packId;
return model;
return _packModelCache;
}
/// Returns true if pack exists and enabled

View file

@ -10,46 +10,43 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:get_it/get_it.dart' as _i1;
import 'package:injectable/injectable.dart' as _i2;
import 'package:isar/isar.dart' as _i4;
import '../../auth/telegram_auth_code_service.dart' as _i20;
import '../../cron/check_payment.dart' as _i41;
import '../../discounts/discounts_manager.dart' as _i8;
import '../../packs/free_packs_distributor.dart' as _i9;
import '../../packs/pack_dto_converter.dart' as _i27;
import '../../packs/pack_manager.dart' as _i28;
import '../../packs/products_price_resolver.dart' as _i13;
import '../../promo_codes/promo_codes_manager.dart' as _i35;
import '../../statistics/achievement_manager.dart' as _i3;
import '../../statistics/session_tracker.dart' as _i15;
import '../../statistics/statistics_calculator.dart' as _i16;
import '../../tests/test_manager.dart' as _i29;
import '../../user/user_manager.dart' as _i22;
import '../ads/ads_manager.dart' as _i7;
import '../mnemo_shelf.dart' as _i26;
import '../purchase/payment_manager.dart' as _i34;
import '../purchase/rustore/rustore_purchase_handler.dart' as _i14;
import '../purchase/yoo_money.dart' as _i31;
import '../subscription/subscription_manager.dart' as _i17;
import '../user/google_api.dart' as _i11;
import '../v2/admin_analytics_api_v2.dart' as _i5;
import '../../auth/telegram_auth_code_service.dart' as _i18;
import '../../cron/check_payment.dart' as _i37;
import '../../database/database.dart' as _i5;
import '../../discounts/discounts_manager.dart' as _i6;
import '../../packs/free_packs_distributor.dart' as _i7;
import '../../packs/pack_dto_converter.dart' as _i28;
import '../../packs/pack_manager.dart' as _i29;
import '../../packs/products_price_resolver.dart' as _i10;
import '../../promo_codes/promo_codes_manager.dart' as _i31;
import '../../statistics/achievement_manager.dart' as _i22;
import '../../statistics/session_tracker.dart' as _i12;
import '../../statistics/statistics_calculator.dart' as _i13;
import '../../tasks/task_manager.dart' as _i16;
import '../../tests/test_manager.dart' as _i33;
import '../../user/user_manager.dart' as _i20;
import '../../user/user_manager_drift.dart' as _i35;
import '../ads/ads_manager.dart' as _i4;
import '../mnemo_shelf.dart' as _i27;
import '../purchase/payment_manager.dart' as _i30;
import '../purchase/rustore/rustore_purchase_handler.dart' as _i11;
import '../purchase/yoo_money.dart' as _i21;
import '../subscription/subscription_manager.dart' as _i14;
import '../user/google_api.dart' as _i8;
import '../v2/admin_analytics_api_v2.dart' as _i3;
import '../v2/admin_auth_api_v2.dart' as _i23;
import '../v2/admin_cards_api_v2.dart' as _i6;
import '../v2/admin_packs_api_v2.dart' as _i32;
import '../v2/admin_users_api_v2.dart' as _i39;
import '../v2/ads_api_v2.dart' as _i40;
import '../v2/auth_api_v2.dart' as _i24;
import '../v2/discounts_api_v2.dart' as _i25;
import '../v2/games_api_v2.dart' as _i10;
import '../v2/jwt_service.dart' as _i12;
import '../v2/packs_api_v2.dart' as _i33;
import '../v2/promocodes_api_v2.dart' as _i36;
import '../v2/purchases_api_v2.dart' as _i37;
import '../v2/subscriptions_api_v2.dart' as _i18;
import '../v2/tasks_api_v2.dart' as _i19;
import '../v2/telegram_bot_api_v2.dart' as _i21;
import '../v2/tests_api_v2.dart' as _i30;
import '../v2/users_api_v2.dart' as _i38;
import '../v2/admin_cards_api_v2.dart' as _i24;
import '../v2/auth_api_v2.dart' as _i25;
import '../v2/discounts_api_v2.dart' as _i26;
import '../v2/jwt_service.dart' as _i9;
import '../v2/promocodes_api_v2.dart' as _i32;
import '../v2/subscriptions_api_v2.dart' as _i15;
import '../v2/tasks_api_v2.dart' as _i17;
import '../v2/telegram_bot_api_v2.dart' as _i19;
import '../v2/tests_api_v2.dart' as _i34;
import '../v2/users_api_v2.dart' as _i36;
import 'modules.dart' as _i38;
extension GetItInjectableX on _i1.GetIt {
// initializes the registration of main-scope dependencies inside of GetIt
@ -62,106 +59,106 @@ extension GetItInjectableX on _i1.GetIt {
environment,
environmentFilter,
);
gh.lazySingleton<_i3.AchievementManager>(
() => _i3.AchievementManager(gh<_i4.Isar>()));
gh.lazySingleton<_i5.AdminAnalyticsApiV2>(() => _i5.AdminAnalyticsApiV2());
gh.lazySingleton<_i6.AdminCardsApiV2>(() => _i6.AdminCardsApiV2());
gh.lazySingleton<_i7.AdsManager>(() => _i7.AdsManager());
gh.lazySingleton<_i8.DiscountsManager>(() => const _i8.DiscountsManager());
gh.lazySingleton<_i9.FreePacksDistributor>(
() => const _i9.FreePacksDistributor());
gh.lazySingleton<_i10.GamesApiV2>(() => _i10.GamesApiV2());
gh.lazySingleton<_i11.GoogleApi>(() => const _i11.GoogleApi());
gh.lazySingleton<_i12.JwtService>(() => _i12.JwtService());
gh.lazySingleton<_i13.ProductsPriceResolver>(
() => _i13.ProductsPriceResolver(gh<_i8.DiscountsManager>()));
gh.lazySingleton<_i14.RustorePurchaseHandler>(
() => _i14.RustorePurchaseHandler());
gh.lazySingleton<_i15.SessionTracker>(
() => _i15.SessionTracker(gh<_i4.Isar>()));
gh.lazySingleton<_i16.StatisticsCalculator>(
() => _i16.StatisticsCalculator());
gh.lazySingleton<_i17.SubscriptionManager>(
() => _i17.SubscriptionManager());
gh.lazySingleton<_i18.SubscriptionsApiV2>(
() => _i18.SubscriptionsApiV2(gh<_i17.SubscriptionManager>()));
gh.lazySingleton<_i19.TasksApiV2>(() => const _i19.TasksApiV2());
gh.lazySingleton<_i20.TelegramAuthCodeService>(
() => _i20.TelegramAuthCodeService());
gh.lazySingleton<_i21.TelegramBotApiV2>(() => _i21.TelegramBotApiV2());
gh.lazySingleton<_i22.UserManager>(() => _i22.UserManager(
gh<_i9.FreePacksDistributor>(),
gh<_i15.SessionTracker>(),
gh<_i16.StatisticsCalculator>(),
gh<_i3.AchievementManager>(),
final appModule = _$AppModule();
gh.lazySingleton<_i3.AdminAnalyticsApiV2>(() => _i3.AdminAnalyticsApiV2());
gh.lazySingleton<_i4.AdsManager>(() => _i4.AdsManager());
gh.singleton<_i5.AppDatabase>(() => appModule.database);
gh.lazySingleton<_i6.DiscountsManager>(() => const _i6.DiscountsManager());
gh.lazySingleton<_i7.FreePacksDistributor>(
() => const _i7.FreePacksDistributor());
gh.lazySingleton<_i8.GoogleApi>(() => const _i8.GoogleApi());
gh.lazySingleton<_i9.JwtService>(
() => _i9.JwtService(gh<_i5.AppDatabase>()));
gh.lazySingleton<_i10.ProductsPriceResolver>(
() => _i10.ProductsPriceResolver(gh<_i6.DiscountsManager>()));
gh.lazySingleton<_i11.RustorePurchaseHandler>(
() => _i11.RustorePurchaseHandler());
gh.lazySingleton<_i12.SessionTracker>(
() => _i12.SessionTracker(gh<_i5.AppDatabase>()));
gh.lazySingleton<_i13.StatisticsCalculator>(
() => _i13.StatisticsCalculator());
gh.lazySingleton<_i14.SubscriptionManager>(
() => _i14.SubscriptionManager(gh<_i5.AppDatabase>()));
gh.lazySingleton<_i15.SubscriptionsApiV2>(
() => _i15.SubscriptionsApiV2(gh<_i14.SubscriptionManager>()));
gh.lazySingleton<_i16.TaskManager>(
() => _i16.TaskManager(gh<_i5.AppDatabase>()));
gh.lazySingleton<_i17.TasksApiV2>(
() => _i17.TasksApiV2(gh<_i16.TaskManager>()));
gh.lazySingleton<_i18.TelegramAuthCodeService>(
() => _i18.TelegramAuthCodeService());
gh.lazySingleton<_i19.TelegramBotApiV2>(() => _i19.TelegramBotApiV2());
gh.lazySingleton<_i20.UserManager>(() => _i20.UserManager(
gh<_i5.AppDatabase>(),
gh<_i7.FreePacksDistributor>(),
));
gh.singleton<_i21.YooMoneyHandler>(() => appModule.yooMoneyHandler);
gh.lazySingleton<_i22.AchievementManager>(
() => _i22.AchievementManager(gh<_i5.AppDatabase>()));
gh.lazySingleton<_i23.AdminAuthApiV2>(() => _i23.AdminAuthApiV2(
gh<_i20.TelegramAuthCodeService>(),
gh<_i22.UserManager>(),
gh<_i12.JwtService>(),
gh<_i18.TelegramAuthCodeService>(),
gh<_i20.UserManager>(),
gh<_i9.JwtService>(),
));
gh.lazySingleton<_i24.AuthApiV2>(() => _i24.AuthApiV2(
gh<_i22.UserManager>(),
gh<_i11.GoogleApi>(),
gh<_i12.JwtService>(),
gh<_i20.TelegramAuthCodeService>(),
gh.factory<_i24.AdminCardsApiV2>(
() => _i24.AdminCardsApiV2(gh<_i5.AppDatabase>()));
gh.lazySingleton<_i25.AuthApiV2>(() => _i25.AuthApiV2(
gh<_i5.AppDatabase>(),
gh<_i20.UserManager>(),
gh<_i8.GoogleApi>(),
gh<_i9.JwtService>(),
gh<_i18.TelegramAuthCodeService>(),
));
gh.lazySingleton<_i25.DiscountsApiV2>(
() => _i25.DiscountsApiV2(gh<_i8.DiscountsManager>()));
gh.lazySingleton<_i26.MnemoShelf>(
() => _i26.MnemoShelf(gh<_i22.UserManager>()));
gh.lazySingleton<_i27.PackDtoConverter>(() => _i27.PackDtoConverter(
gh<_i13.ProductsPriceResolver>(),
gh<_i7.AdsManager>(),
gh.lazySingleton<_i26.DiscountsApiV2>(
() => _i26.DiscountsApiV2(gh<_i6.DiscountsManager>()));
gh.lazySingleton<_i27.MnemoShelf>(
() => _i27.MnemoShelf(gh<_i20.UserManager>()));
gh.lazySingleton<_i28.PackDtoConverter>(() => _i28.PackDtoConverter(
gh<_i10.ProductsPriceResolver>(),
gh<_i4.AdsManager>(),
));
gh.lazySingleton<_i28.PackManager>(
() => _i28.PackManager(gh<_i27.PackDtoConverter>()));
gh.lazySingleton<_i29.TestManager>(
() => _i29.TestManager(gh<_i27.PackDtoConverter>()));
gh.lazySingleton<_i30.TestsApiV2>(() => _i30.TestsApiV2(
gh<_i29.TestManager>(),
gh<_i22.UserManager>(),
gh.lazySingleton<_i29.PackManager>(() => _i29.PackManager(
gh<_i5.AppDatabase>(),
gh<_i28.PackDtoConverter>(),
));
gh.lazySingleton<_i31.YooMoneyHandler>(
() => _i31.YooMoneyHandler(gh<_i28.PackManager>()));
gh.lazySingleton<_i32.AdminPacksApiV2>(() => _i32.AdminPacksApiV2(
gh<_i28.PackManager>(),
gh<_i27.PackDtoConverter>(),
gh.lazySingleton<_i30.PaymentManager>(() => _i30.PaymentManager(
gh<_i5.AppDatabase>(),
gh<_i29.PackManager>(),
gh<_i14.SubscriptionManager>(),
gh<_i21.YooMoneyHandler>(),
gh<_i11.RustorePurchaseHandler>(),
gh<_i10.ProductsPriceResolver>(),
));
gh.lazySingleton<_i33.PacksApiV2>(() => _i33.PacksApiV2(
gh<_i28.PackManager>(),
gh<_i29.TestManager>(),
gh.lazySingleton<_i31.PromoCodesManager>(() => _i31.PromoCodesManager(
gh<_i5.AppDatabase>(),
gh<_i30.PaymentManager>(),
));
gh.lazySingleton<_i34.PaymentManager>(() => _i34.PaymentManager(
gh<_i28.PackManager>(),
gh<_i17.SubscriptionManager>(),
gh<_i31.YooMoneyHandler>(),
gh<_i14.RustorePurchaseHandler>(),
gh<_i13.ProductsPriceResolver>(),
gh.lazySingleton<_i32.PromocodesApiV2>(
() => _i32.PromocodesApiV2(gh<_i31.PromoCodesManager>()));
gh.lazySingleton<_i33.TestManager>(() => _i33.TestManager(
gh<_i5.AppDatabase>(),
gh<_i28.PackDtoConverter>(),
));
gh.lazySingleton<_i35.PromoCodesManager>(
() => _i35.PromoCodesManager(gh<_i34.PaymentManager>()));
gh.lazySingleton<_i36.PromocodesApiV2>(
() => _i36.PromocodesApiV2(gh<_i35.PromoCodesManager>()));
gh.lazySingleton<_i37.PurchasesApiV2>(() => _i37.PurchasesApiV2(
gh<_i34.PaymentManager>(),
gh<_i28.PackManager>(),
gh.lazySingleton<_i34.TestsApiV2>(() => _i34.TestsApiV2(
gh<_i33.TestManager>(),
gh<_i20.UserManager>(),
));
gh.lazySingleton<_i38.UsersApiV2>(() => _i38.UsersApiV2(
gh<_i22.UserManager>(),
gh<_i34.PaymentManager>(),
gh<_i16.StatisticsCalculator>(),
gh.lazySingleton<_i35.UserManager>(() => _i35.UserManager(
gh<_i5.AppDatabase>(),
gh<_i7.FreePacksDistributor>(),
gh<_i12.SessionTracker>(),
gh<_i13.StatisticsCalculator>(),
gh<_i22.AchievementManager>(),
));
gh.lazySingleton<_i39.AdminUsersApiV2>(() => _i39.AdminUsersApiV2(
gh<_i22.UserManager>(),
gh<_i34.PaymentManager>(),
gh.lazySingleton<_i36.UsersApiV2>(() => _i36.UsersApiV2(
gh<_i20.UserManager>(),
gh<_i30.PaymentManager>(),
gh<_i13.StatisticsCalculator>(),
));
gh.lazySingleton<_i40.AdsApiV2>(() => _i40.AdsApiV2(
gh<_i7.AdsManager>(),
gh<_i34.PaymentManager>(),
));
gh.lazySingleton<_i41.CheckPaymentTask>(
() => _i41.CheckPaymentTask(gh<_i34.PaymentManager>()));
gh.lazySingleton<_i37.CheckPaymentTask>(
() => _i37.CheckPaymentTask(gh<_i30.PaymentManager>()));
return this;
}
}
class _$AppModule extends _i38.AppModule {}

View file

@ -2,6 +2,7 @@ import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'injector.config.dart';
import 'modules.dart';
final getIt = GetIt.instance;

View 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: ''),
);
}

View file

@ -6,23 +6,23 @@ import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';
import 'v2/ads_api_v2.dart';
// import 'v2/ads_api_v2.dart'; // disabled
import 'v2/admin_analytics_api_v2.dart';
import 'v2/admin_auth_api_v2.dart';
import 'v2/admin_cards_api_v2.dart';
import 'v2/admin_packs_api_v2.dart';
import 'v2/admin_users_api_v2.dart';
// import 'v2/admin_cards_api_v2.dart'; // disabled
// import 'v2/admin_packs_api_v2.dart'; // disabled
// import 'v2/admin_users_api_v2.dart'; // uses isar
import 'v2/auth_api_v2.dart';
import 'v2/discounts_api_v2.dart';
import 'v2/games_api_v2.dart';
import 'v2/packs_api_v2.dart';
import 'v2/purchases_api_v2.dart';
// import 'v2/games_api_v2.dart'; // disabled
// import 'v2/packs_api_v2.dart'; // disabled
// import 'v2/purchases_api_v2.dart'; // disabled
import 'v2/promocodes_api_v2.dart';
import 'v2/subscriptions_api_v2.dart';
import 'v2/tasks_api_v2.dart';
import 'v2/tests_api_v2.dart';
import 'v2/users_api_v2.dart';
import 'v2/telegram_bot_api_v2.dart';
// import 'v2/users_api_v2.dart'; // uses isar
// import 'v2/telegram_bot_api_v2.dart'; // uses isar
import 'v2/authorize_v2.dart';
import 'v2/telegram_bot_auth_middleware.dart';
import 'v2/jwt_service.dart';
@ -48,30 +48,20 @@ class MnemoShelf {
final port = int.tryParse(portArg ?? '') ?? 3000;
// V2 APIs (new RESTful API with OAuth2/JWT)
final v2Routers = [
getIt.get<AuthApiV2>().router,
getIt.get<AdminAuthApiV2>().router,
getIt.get<AdminAnalyticsApiV2>().router,
getIt.get<AdminCardsApiV2>().router,
getIt.get<AdminPacksApiV2>().router,
getIt.get<PacksApiV2>().router,
getIt.get<TestsApiV2>().router,
getIt.get<GamesApiV2>().router,
getIt.get<PurchasesApiV2>().router,
getIt.get<AdsApiV2>().router,
getIt.get<UsersApiV2>().router,
getIt.get<PromocodesApiV2>().router,
getIt.get<SubscriptionsApiV2>().router,
getIt.get<AdminUsersApiV2>().router,
getIt.get<DiscountsApiV2>().router,
getIt.get<TasksApiV2>().router,
getIt.get<TelegramBotApiV2>().router,
];
final v2Router = Router();
final v2Router = v2Routers.fold<Router>(
Router(),
(router, child) => router..mount('/', child),
);
// Add routers individually to avoid type issues
v2Router.mount('/', getIt.get<AuthApiV2>().router);
v2Router.mount('/', getIt.get<AdminAuthApiV2>().router);
v2Router.mount('/', getIt.get<AdminAnalyticsApiV2>().router);
// v2Router.mount('/', getIt.get<PacksApiV2>().router); // may use isar
v2Router.mount('/', getIt.get<TestsApiV2>().router);
// v2Router.mount('/', getIt.get<GamesApiV2>().router); // disabled
// v2Router.mount('/', getIt.get<PurchasesApiV2>().router); // disabled
v2Router.mount('/', getIt.get<PromocodesApiV2>().router);
v2Router.mount('/', getIt.get<SubscriptionsApiV2>().router);
v2Router.mount('/', getIt.get<DiscountsApiV2>().router);
v2Router.mount('/', getIt.get<TasksApiV2>().handler);
// Note: rootRouter is not used because we manually route to v1/v2 handlers
// for different authentication middleware. Keeping for reference.

View file

@ -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()),
);
}
}

View file

@ -7,14 +7,17 @@ import 'package:googleapis/firestore/v1.dart' as fs;
import 'package:googleapis_auth/auth_io.dart' as auth;
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/api/purchase/google_play_purchase_handler.dart';
import 'package:mnemo_cards_backend/api/purchase/rustore/rustore_purchase_response.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:yookassa_client/yookassa_client.dart';
import 'package:drift/drift.dart' as drift;
import 'payment_drift_extension.dart';
import '../../main.dart' as backend_main;
import '../../main.dart';
import '../../packs/pack_manager.dart';
import '../../packs/products_price_resolver.dart';
import '../subscription/subscription_manager.dart';
@ -24,6 +27,7 @@ import 'yoo_money.dart';
@lazySingleton
class PaymentManager {
final AppDatabase _db;
final PackManager _packManager;
final SubscriptionManager _subscriptionManager;
late final GooglePlayPurchaseHandler googlePurchaseHandler;
@ -32,6 +36,7 @@ class PaymentManager {
final ProductsPriceResolver _productsPriceResolver;
PaymentManager(
this._db,
this._packManager,
this._subscriptionManager,
this._yooMoneyHandler,
@ -39,453 +44,251 @@ class PaymentManager {
this._productsPriceResolver,
);
/// Creates the Google Play and Apple Store [PurchaseHandler]
/// and their dependencies
/// Создать платеж в базе данных
Future<PaymentDto> createPayment(PaymentDto paymentDto, int userId) async {
final companion = paymentDto.toCompanion(userId);
final paymentId = await _db.paymentDao.createPayment(companion);
final payment = await _db.paymentDao.getPaymentById(paymentId);
if (payment == null) {
throw Exception('Failed to create payment');
}
return payment.toDto();
}
/// Обновить платеж в базе данных
Future<void> updatePayment(int paymentId, PaymentDto paymentDto) async {
final companion = paymentDto.toUpdateCompanion(paymentId);
await _db.paymentDao.updatePaymentCompanion(companion);
}
/// Получить платеж по ID
Future<PaymentDto?> getPaymentById(int id) async {
final payment = await _db.paymentDao.getPaymentById(id);
return payment?.toDto();
}
/// Создать обработчики платежей Google Play
Future<Map<String, GooglePlayPurchaseHandler>>
_createPurchaseHandlers() async {
// Configure Android Publisher API access
final serviceAccountGooglePlay =
File('data/service-account-google-play.json').readAsStringSync();
final clientCredentialsGooglePlay =
auth.ServiceAccountCredentials.fromJson(serviceAccountGooglePlay);
final clientGooglePlay =
await auth.clientViaServiceAccount(clientCredentialsGooglePlay, [
ap.AndroidPublisherApi.androidpublisherScope,
]);
final androidPublisher = ap.AndroidPublisherApi(clientGooglePlay);
// Configure Firestore API access
final serviceAccountFirebase =
File('assets/service-account-firebase.json').readAsStringSync();
final clientCredentialsFirebase =
auth.ServiceAccountCredentials.fromJson(serviceAccountFirebase);
final clientFirebase =
await auth.clientViaServiceAccount(clientCredentialsFirebase, [
fs.FirestoreApi.cloudPlatformScope,
]);
final firestoreApi = fs.FirestoreApi(clientFirebase);
final dynamic json = jsonDecode(serviceAccountFirebase);
final projectId = json['project_id'] as String;
final iapRepository = IapRepository(firestoreApi, projectId);
return {
'google_play': GooglePlayPurchaseHandler(
androidPublisher,
iapRepository,
),
// 'app_store': AppStorePurchaseHandler(
// iapRepository,
// ),
};
// TODO: Implement proper Google Play purchase handlers with service account
// For now, return empty map
return {};
}
Future<void> init() async {
googlePurchaseHandler = (await _createPurchaseHandlers()).values.first;
}
Future<YookassaPaymentDto?> createYooMoneyPayment(
String productId,
MnemoCardsProductType productType,
UserModel? user,
) async {
if (user == null) {
return null;
}
if (productType == MnemoCardsProductType.pack) {
final pack = await _packManager.getBuyPage(productId, user);
if (pack == null) {
return null;
}
final dto = await _yooMoneyHandler.createPayment(
productId: pack.id,
productType: MnemoCardsProductType.pack,
// Price with discounts
price: pack.price!,
title: pack.title,
user: user,
);
return dto;
} else if (productType == MnemoCardsProductType.subscription) {
await user.subscriptionModel.load();
if (user.subscriptionModel.value?.isActive == true) {
return null;
}
final sub = await _subscriptionManager.getSubscriptionPlan(productId);
if (sub == null) {
return null;
}
final dto = await _yooMoneyHandler.createPayment(
productId: productId,
productType: MnemoCardsProductType.subscription,
title: sub.ui?.title ?? 'Подписка',
price: await _productsPriceResolver.userSubscriptionPrice(user, sub),
user: user,
);
return dto;
}
return null;
}
// Future<String?> createGooglePayment(String packId, UserModel? user) async {
// if (user == null) {
// return null;
// }
// final pack = await _packManager.getBuyPage(packId, user);
// if (pack == null) {
// return null;
// }
// final product = await googlePurchaseHandler.getProduct(pack.googlePlayId!);
// if (product == null) {
// return null;
// }
// isar.writeTxnSync(() {
// final paymentId = isar.paymentModels.putSync(
// PaymentModel.google(
// amount: product.defaultPrice.priceMicros,
// currency: product.defaultPrice.currency,
// userId: user.id!,
// packs: [pack.id],
// externalToken: payment.id,
// meta: jsonEncode(payment.toJson()),
// ),
// );
// isar.userModels.putSync(
// user.copyWith(purchases: [...user.purchases, paymentId.toString()]),
// );
// })
//
// if (url != null) {
// [3, 5, 10].map(
// (e) =>
// Future.delayed(
// Duration(minutes: e),
// () => checkAndProcessUserPayments(user),
// ),
// );
// }
// return url;
// }
Future<void> processPayment(PaymentModel paymentModel) async {
if (paymentModel.status == PaymentStatus.processed) {
/// Обработать платеж - дать доступ к купленным пакетам и подпискам
Future<void> processPayment(Payment payment) async {
if (payment.status == PaymentStatus.processed.name) {
log('Payment already processed');
return;
}
if (paymentModel.status == PaymentStatus.succeeded) {
final user = await isar.txn(() async {
final user = await isar.userModels.get(paymentModel.userId);
await user?.subscriptionModel.load();
return user;
});
if (user == null) {
print('User not found ${paymentModel.userId}');
return;
}
final packs = await isar.txn(
() async => (await isar.cardPackModels.getAll(
{
...paymentModel.packs.map((e) => int.parse(e)),
...paymentModel.products
.where((p) => p.type == MnemoCardsProductModelType.pack)
.map((e) => e.id)
.whereNotNull(),
}.toList(),
))
.whereNotNull(),
);
final subs = paymentModel.products
.where((p) => p.type == MnemoCardsProductModelType.subscription)
.toList();
SubscriptionPlanModel? planModel;
if (subs.isNotEmpty) {
planModel = await _subscriptionManager
.getSubscriptionPlan(subs.first.id.toString());
}
await isar.writeTxn(() async {
if (packs.isNotEmpty) {
await (user.packs..addAll(packs)).save();
}
if (planModel != null) {
final userSubscriptionModel = UserSubscriptionModel(
start: DateTime.now(),
finish: DateTime.now().add(Duration(days: planModel.durationDays)),
features: [...planModel.features],
);
user.subscriptionModel.value = userSubscriptionModel;
await isar.userSubscriptionModels.put(userSubscriptionModel);
await user.subscriptionModel.save();
}
await isar.userModels.put(user);
await isar.paymentModels.put(
paymentModel.copyWith(
status: PaymentStatus.processed,
),
);
});
} else {
throw Exception('Payment status is not success');
if (payment.status != PaymentStatus.succeeded.name) {
throw Exception('Payment status is not succeeded');
}
}
Future<List<PaymentModel>> getUserPayments(String userIdString) async {
final userId = int.tryParse(userIdString);
if (userId == null) return [];
return isar.txn(() async {
final user = await isar.userModels.get(userId);
if (user == null) return [];
final purchases =
user.purchases.map((e) => int.tryParse(e)).whereNotNull();
if (purchases.isEmpty) return [];
final payments =
(await isar.paymentModels.getAll(purchases.toList())).whereNotNull();
return payments.toList();
// Получить пользователя
final user = await _db.userDao.getUserWithDataById(payment.userId);
if (user == null) {
log('User not found: ${payment.userId}');
return;
}
// Извлечь IDs пакетов из продуктов
final packIds = <int>[];
if (payment.products != null) {
for (final product in payment.products!) {
final productMap = product as Map<String, dynamic>;
if (productMap['type'] == 'pack' && productMap['id'] != null) {
packIds.add(int.parse(productMap['id'].toString()));
}
}
}
// Извлечь IDs подписок из продуктов
final subscriptionIds = <int>[];
if (payment.products != null) {
for (final product in payment.products!) {
final productMap = product as Map<String, dynamic>;
if (productMap['type'] == 'subscription' && productMap['id'] != null) {
subscriptionIds.add(int.parse(productMap['id'].toString()));
}
}
}
await _db.transaction(() async {
// Дать доступ к пакетам
for (final packId in packIds) {
await _db.userDao.grantPackAccess(
userId: payment.userId,
packId: packId,
grantType: 'purchase',
);
log('Granted access to pack $packId for user ${payment.userId}');
}
// Создать подписки
for (final subscriptionId in subscriptionIds) {
final plan = await _db.subscriptionDao.getPlanById(subscriptionId);
if (plan != null) {
final now = DateTime.now();
final endDate = now.add(Duration(days: plan.durationDays));
await _db.subscriptionDao.createUserSubscription(
UserSubscriptionsCompanion.insert(
userId: payment.userId,
start: now,
finish: endDate,
features: drift.Value(plan.features as List<dynamic>),
),
);
log('Created subscription for user ${payment.userId}, plan: $subscriptionId');
}
}
// Обновить статус платежа
await _db.paymentDao.updatePaymentStatus(payment.id, PaymentStatus.processed.name);
log('Payment ${payment.id} processed successfully');
});
}
Future<List<PaymentModel>> checkAndProcessUserPayments(
UserModel model,
) async {
final payments = await getUserPayments(model.id.toString());
final updatedPayments = <PaymentModel>[];
for (final payment in payments) {
try {
updatedPayments.add(
await checkAndProcessPayment(payment),
);
} catch (e) {
print(e);
}
}
return updatedPayments
.where((element) => element.status == PaymentStatus.succeeded)
.toList();
}
Future<bool> checkYookassaPayment(
MnemoCardsProductDto product,
String token,
UserModel? user,
) async {
final modelId = int.tryParse(token);
if (modelId == null) {
log('Invalid payment model id: ${token}');
return false;
}
final model = await isar.txn(() => isar.paymentModels.get(modelId));
if (model == null) {
log('Payment model with id ${modelId} not found');
return false;
}
if (model.externalToken == null) {
log('Payment model ${modelId} has no external token');
return false;
}
final result = await checkAndProcessPayment(model);
return result.status == PaymentStatus.succeeded;
}
/// Проверить платеж Google Play
Future<bool> checkGooglePayment({
required MnemoCardsProductDto product,
required String productId,
required String token,
required UserModel? user,
}) async {
final productId = product.id.toString();
if (product.type == MnemoCardsProductType.pack) {
final productPurchase = await googlePurchaseHandler.handleNonSubscription(
productId: productId,
token: token,
);
if (productPurchase == null) {
if (user == null) return false;
try {
// Найти платеж по external token
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
if (payment == null) {
log('Payment not found for token: $token');
return false;
}
final pack = await isar.txn(
() => isar.cardPackModels
.filter()
.googlePlayIdEqualTo(productId)
.findFirst(),
);
PaymentModel? model = await isar.txn(
() =>
isar.paymentModels.filter().externalTokenEqualTo(token).findFirst(),
);
final googleStatus = GooglePlayPurchaseHandler.nonSubscriptionStatusFrom(
productPurchase.purchaseState);
PaymentModel updatedModel;
if (model == null) {
final product = (await googlePurchaseHandler.getProduct(productId))!;
final price =
product.prices?[productPurchase.regionCode] ?? product.defaultPrice;
final amount = int.tryParse(price?.priceMicros ?? '')?.toString() ?? '';
final currency = price?.currency ?? '';
updatedModel = PaymentModel.google(
amount: amount,
currency: currency,
userId: user!.id!,
externalToken: token,
date: DateTime.now(),
packs: [pack?.id.toString() ?? ''],
products: [
MnemoCardsProductModelBase.pack(pack?.id),
],
subscription: productId == 'subscription',
meta: jsonEncode(productPurchase.toJson()),
status: PaymentStatus.created,
);
await isar.writeTxn(() {
return isar.paymentModels.put(updatedModel);
});
} else {
updatedModel =
model.copyWith(meta: jsonEncode(productPurchase.toJson()));
await isar.writeTxn(() => isar.paymentModels.put(updatedModel));
}
if (googleStatus == PaymentStatus.succeeded) {
try {
processPayment(updatedModel);
} catch (error) {
print(error);
return false;
}
}
final acknowledged =
await googlePurchaseHandler.acknowledge(productId, token);
// Проверить статус в Google Play
final acknowledged = await googlePurchaseHandler.acknowledge(productId, token);
if (!acknowledged) {
await isar.writeTxn(
() => isar.paymentModels.put(
updatedModel.copyWith(status: PaymentStatus.waiting),
),
);
await _db.paymentDao.updatePaymentStatus(payment.id, PaymentStatus.waiting.name);
return false;
}
// Обновить статус платежа
await _db.paymentDao.updatePaymentStatus(payment.id, PaymentStatus.succeeded.name);
// Обработать платеж
await processPayment(payment);
return true;
} catch (e) {
log('Error checking Google payment: $e');
return false;
}
return false;
}
/// Проверить платеж RuStore
Future<bool> checkRustorePayment({
required String productId,
required String subscriptionToken,
required UserModel? user,
}) async {
final rustorePurchaseResponse =
await _rustorePurchaseHandler.checkPayment(subscriptionToken);
final pack = await isar.txn(
() =>
isar.cardPackModels.filter().rustoreIdEqualTo(productId).findFirst(),
);
PaymentModel? model = await isar.txn(
() => isar.paymentModels
.filter()
.externalTokenEqualTo(subscriptionToken)
.findFirst(),
);
final meta = rustorePurchaseResponse?.let(jsonEncode);
if (user == null) return false;
PaymentModel updatedModel;
if (model == null) {
updatedModel = PaymentModel.rustore(
amount: pack?.price ?? 'no-price',
currency: 'rub',
userId: user!.id!,
externalToken: subscriptionToken,
date: DateTime.now(),
products: [
MnemoCardsProductModelBase.pack(pack?.id),
],
packs: [pack?.id.toString() ?? ''],
subscription: productId == 'subscription',
meta: meta,
status: rustorePurchaseResponse?.invoiceStatus.toPaymentStatus() ??
PaymentStatus.created,
);
await isar.writeTxn(() {
return isar.paymentModels.put(updatedModel);
});
} else {
updatedModel = model.copyWith(meta: meta);
await isar.writeTxn(() => isar.paymentModels.put(updatedModel));
}
if (updatedModel.status == PaymentStatus.succeeded) {
try {
processPayment(updatedModel);
} catch (error) {
print(error);
try {
final rustorePurchaseResponse =
await _rustorePurchaseHandler.checkPayment(subscriptionToken);
// Найти платеж по продукту
final payments = await _db.paymentDao.getPaymentsByProduct(productId);
final payment = payments.isNotEmpty ? payments.first : null;
if (payment == null) {
log('Payment not found for product: $productId');
return false;
}
return true;
if (rustorePurchaseResponse?.invoiceStatus.name == 'paid') {
await _db.paymentDao.updatePaymentStatus(payment.id, PaymentStatus.succeeded.name);
await processPayment(payment);
return true;
}
return false;
} catch (e) {
log('Error checking RuStore payment: $e');
return false;
}
return false;
}
Future<PaymentModel> checkAndProcessPayment(PaymentModel model) async {
var paymentStatus = model.status;
if (model.status == PaymentStatus.created) {
if (model.paymentSystem == PaymentSystem.rustore) {
if (model.externalToken != null) {
final payment =
await _rustorePurchaseHandler.checkPayment(model.externalToken!);
paymentStatus =
payment?.invoiceStatus.toPaymentStatus() ?? PaymentStatus.unknown;
}
} else if (model.paymentSystem == PaymentSystem.yookassa) {
print(
'Checking ${model.paymentSystem.name} ${model.status.name} ${model.id}');
try {
final payment =
await _yooMoneyHandler.checkPayment(model.externalToken!);
paymentStatus = payment.status.toPaymentStatus();
} on DioException catch (e) {
print(
'Dio exception ${model.paymentSystem.name} ${model.status.name} ${model.id}');
print('Yookassa dio error: ${e}');
final error = e.error;
if (error is YookassaException) {
print('Yookassa payment error: ${e}');
if (error.code == YookassaErrorCode.notFound) {
paymentStatus = PaymentStatus.unknown;
print('Yookassa payment not found: ${model.externalToken}');
} else {
rethrow;
}
}
} on Object catch (e, s) {
print(
'Exception ${model.paymentSystem.name} ${model.status.name} ${model.id}');
print(e);
print(s);
}
/// Проверить платеж YooKassa
Future<bool> checkYookassaPayment(String token) async {
try {
final payment = await _db.paymentDao.getPaymentByExternalToken(token);
if (payment == null) {
log('Payment not found for token: $token');
return false;
}
print(
'Checked ${model.paymentSystem.name} ${model.status.name}->${paymentStatus} ${model.id}');
}
final updatedModel = model.copyWith(
status: paymentStatus,
// Проверить статус в YooKassa
final yookassaPayment = await _yooMoneyHandler.checkPayment(token);
if (yookassaPayment.status == 'succeeded') {
await _db.paymentDao.updatePaymentStatus(payment.id, PaymentStatus.succeeded.name);
await processPayment(payment);
return true;
} else if (yookassaPayment.status == 'canceled') {
await _db.paymentDao.updatePaymentStatus(payment.id, PaymentStatus.canceled.name);
}
return false;
} catch (e) {
log('Error checking YooKassa payment: $e');
return false;
}
}
/// Создать URL для оплаты через YooKassa
Future<String> createYookassaUrl({
required String amount,
required String description,
required String userId,
}) async {
final yookassaPayment = await _yooMoneyHandler.createPayment(
amount: amount,
description: description,
userId: userId,
);
if (model.status != updatedModel.status) {
await isar.writeTxn(() => isar.paymentModels.put(updatedModel));
if (yookassaPayment.confirmationUrl == null) {
throw Exception('Failed to create YooKassa payment URL');
}
if (updatedModel.status == PaymentStatus.succeeded) {
print(
'Processing ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}');
try {
processPayment(updatedModel);
} catch (error) {
print(error);
print(
'Process error ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}');
return model;
}
await isar.writeTxn(() => isar.paymentModels.put(updatedModel));
print(
'Processed ${updatedModel.paymentSystem.name} ${updatedModel.status.name} ${updatedModel.id}',
);
} else if (updatedModel.status == PaymentStatus.unknown) {
await isar.writeTxn(() => isar.paymentModels.put(updatedModel));
}
return updatedModel;
// Создать запись платежа в БД
final paymentDto = PaymentDto(
amount: amount,
currency: 'RUB',
date: DateTime.now(),
status: PaymentStatus.created,
paymentSystem: PaymentSystem.yookassa,
packs: [],
subscription: false,
products: [], // TODO: add products
externalToken: yookassaPayment.id,
meta: null,
);
await createPayment(paymentDto, int.parse(userId));
return yookassaPayment.confirmationUrl!;
}
}
/// Получить платежи пользователя
Future<List<PaymentDto>> getUserPayments(String userIdString) async {
final userId = int.tryParse(userIdString);
if (userId == null) return [];
final payments = await _db.paymentDao.getPaymentsByUserId(userId);
return payments.map((p) => p.toDto()).toList();
}
}

View 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;
}
}

View file

@ -1,167 +1,47 @@
import 'dart:convert';
import 'dart:developer';
import 'package:dio/dio.dart';
import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:yookassa_client/yookassa_client.dart';
import '../../main.dart';
import '../../packs/pack_manager.dart';
/// Wrapper for YooKassa payment (simplified)
class YookassaPayment {
final String id;
final String status;
final String? confirmationUrl;
YookassaPayment({
required this.id,
required this.status,
this.confirmationUrl,
});
}
@LazySingleton()
class YooMoneyHandler {
final testKey = 'test_Px7SnKrsT0fO8ZKXmnzNqMknuHne7LwG-ypKhJnw9Ro';
final testShop = '382060';
final String _shopId;
final String _secretKey;
final key = 'live_17KhhN5jmobRyjS2LKHPOBVs6noAtJYCg4Wyd_-3byQ';
final shop = '380354';
YooMoneyHandler({
required String shopId,
required String secretKey,
}) : _shopId = shopId,
_secretKey = secretKey;
late final _yookassaClient = YookassaClient(
Dio(),
credentials: YookassaAuthCredentials(
shopId: shop,
secretKey: key,
),
);
late final _testYookassaClient = YookassaClient(
Dio(),
credentials: YookassaAuthCredentials(
shopId: testShop,
secretKey: testKey,
),
);
Map<String, DateTime> _testPayments = {};
YookassaClient get yookassaClient => _yookassaClient;
final PackManager _packManager;
YooMoneyHandler(this._packManager);
Future<YookassaPaymentDto?> createPayment({
required String productId,
required String price,
required String title,
required UserModel user,
required MnemoCardsProductType productType,
Future<YookassaPayment> createPayment({
required String amount,
required String description,
required String userId,
}) async {
if (user.id == null) {
return null;
}
final reservedPaymentId = await isar.writeTxn(
() => isar.paymentModels.put(
PaymentModel.empty(
date: DateTime.now(),
userId: user.id!,
),
),
// TODO: Implement real YooKassa API integration
// For now, return stub
return YookassaPayment(
id: 'test_payment_${DateTime.now().millisecondsSinceEpoch}',
status: 'pending',
confirmationUrl: 'https://yookassa.ru/payment/test',
);
final checkPaymentUrl = PurchaseCheckDeeplink(
id: productId,
productType: productType,
token: reservedPaymentId.toString(),
paymentSystem: PaymentSystem.yookassa,
).uri.toString();
final amount = Amount(
value: price.replaceAll(new RegExp(r"\D"), ""),
currency: 'RUB',
);
final createdPaymentRequest = CreatePaymentRequest(
amount: amount,
receipt: YookassaReceipt(
customer: YookassaCustomer(
email: user.email,
),
items: [
YookassaItem(description: title, quantity: '1', amount: amount),
],
),
confirmation: YookassaConfirmation.redirect(
returnUrl: checkPaymentUrl,
),
capture: true,
description: title,
);
try {
final payment = await yookassaClient.createPayment(
paymentRequest: createdPaymentRequest,
);
isar.writeTxn(() async {
final paymentId = await isar.paymentModels.put(
PaymentModel.yooKassa(
id: reservedPaymentId,
amount: payment.amount.value,
currency: payment.amount.currency,
date: DateTime.now(),
userId: user.id!,
packs: [
if (productType == MnemoCardsProductType.pack) productId,
],
subscription: productType == MnemoCardsProductType.subscription,
externalToken: payment.id,
meta: jsonEncode(payment.toJson()),
products: [
if (productType == MnemoCardsProductType.pack)
MnemoCardsProductModelBase.pack(
int.tryParse(productId),
)
else if (productType == MnemoCardsProductType.subscription)
MnemoCardsProductModelBase.subscription(
int.tryParse(productId),
)
],
),
);
await isar.userModels.put(
user.copyWith(purchases: [...user.purchases, paymentId.toString()]),
);
});
final url = payment.confirmation?.maybeMap(
embedded: (_) {},
external: (_) {},
mobileApplication: (_) {},
qr: (_) {},
redirect: (redirect) => redirect.confirmationUrl,
orElse: () => null,
);
if (url == null) {
throw Exception('Yookassa url is null');
}
return YookassaPaymentDto(
purchaseUrl: url,
checkUrl: checkPaymentUrl,
);
} on YookassaException catch (e, s) {
print(e);
log('YookassaException when creating Yookassa payment',
error: e, stackTrace: s);
} on Exception catch (e, s) {
print(e);
log('Error when creating Yookassa payment', error: e, stackTrace: s);
}
return null;
}
Future<YookassaPayment> checkPayment(String yookassaId) async {
final payment = await yookassaClient.getPaymentInfo(
paymentId: yookassaId,
Future<YookassaPayment> checkPayment(String paymentId) async {
// TODO: Implement real YooKassa API checking
return YookassaPayment(
id: paymentId,
status: 'pending',
);
return payment;
}
}
extension YookassaPaymentStatusExt on YookassaPaymentStatus {
PaymentStatus toPaymentStatus() => switch (this) {
YookassaPaymentStatus.pending => PaymentStatus.created,
YookassaPaymentStatus.waitingForCapture => PaymentStatus.waiting,
YookassaPaymentStatus.succeeded => PaymentStatus.succeeded,
YookassaPaymentStatus.canceled => PaymentStatus.canceled,
};
}
}

View 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,
};
}

View file

@ -1,139 +1,42 @@
import 'dart:developer';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/api/subscription/extension.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import '../../main.dart';
@lazySingleton
class SubscriptionManager {
final AppDatabase _db;
SubscriptionManager(this._db);
Future<SubscriptionDto> getSubscriptionDto(UserModel user) async {
await user.subscriptionModel.load();
final subscription = user.subscriptionModel.value;
if (subscription != null && subscription.isActive) {
return _activeSubscriptionDto(user, subscription);
} else {
return _buySubscriptionDto(user);
}
}
Future<SubscriptionPlanModel?> getSubscriptionPlan(String id) async {
final intId = int.tryParse(id);
if (intId == null) {
log('Incorrect id: $id');
return null;
}
final model = await isar.txn(() => isar.subscriptionPlanModels.get(intId));
return model;
}
Future<SubscriptionDto> _activeSubscriptionDto(
UserModel user,
UserSubscriptionModel subscription,
) async {
final days = subscription.finish.difference(DateTime.now()).abs().inDays;
// TODO: Implement with Drift
return SubscriptionDto(
page: SubscriptionPageDto(
title: 'Ваша подписка',
items: [
TextItem(
title: '',
),
TextItem(
title:
days > 0 ? 'Действует еще ${days} дней' : 'Закончится сегодня',
),
SpacerItem(),
],
plans: [],
),
isActive: true,
start: user.subscriptionModel.value?.start,
finish: user.subscriptionModel.value?.finish,
);
}
Future<SubscriptionDto> _buySubscriptionDto(UserModel user) async {
final plans = await isar.txn(
() => isar.subscriptionPlanModels
.filter()
.paymentSystemEqualTo(PaymentSystem.yookassa)
.findAll(),
);
plans.sort((p, n) => p.durationDays.compareTo(n.durationDays));
return SubscriptionDto(
page: SubscriptionPageDto(
title: 'Подписка',
items: [
SpacerItem(
flex: 1,
height: 10,
),
TextItem(title: 'Открывает все наборы!', color: '#ff'),
TextItem(
title: 'Убирает всю рекламу!',
),
SpacerItem(
flex: 2,
),
],
plans: [
...await Future.wait(
plans.map(
(plan) => plan.toDto(),
),
),
],
),
page: null,
isActive: false,
start: null,
finish: null,
);
}
Future<List<SubscriptionPlanAdminDto>> getAllSubscriptionPlans() async {
final models =
await isar.txn(() => isar.subscriptionPlanModels.where().findAll());
return Future.wait(models.map((model) => model.toAdminDto()));
Future<SubscriptionPlanModel?> getSubscriptionPlan(String id) async {
final intId = int.tryParse(id);
if (intId == null) return null;
final plan = await _db.subscriptionDao.getPlanById(intId);
if (plan == null) return null;
// TODO: Convert SubscriptionPlan (Drift) to SubscriptionPlanModel (Isar)
return null; // Temporary return null
}
Future<bool> deleteSubscriptionPlans(String id) async {
final intId = int.parse(id);
return isar.writeTxn(() {
return isar.subscriptionPlanModels.delete(intId);
});
Future<void> createSubscription(UserModel user, SubscriptionPlanModel plan) async {
// TODO: Implement with Drift
throw UnimplementedError('SubscriptionManager.createSubscription not implemented');
}
Future<bool> addSubscriptionPlans(SubscriptionPlanAdminDto dto) async {
final model = await int.tryParse(dto.id ?? '')
?.let((id) => isar.subscriptionPlanModels.get(id)) ??
SubscriptionPlanModel(
ui: null,
price: dto.price,
currency: dto.currency,
durationDays: dto.durationDays,
features: dto.features,
paymentSystem: dto.paymentSystem,
paymentId: dto.paymentId,
);
final updatedModel = model.copyWith(
id: model.id,
ui: dto.ui.toModel(),
price: dto.price,
currency: dto.currency,
durationDays: dto.durationDays,
features: dto.features,
paymentId: dto.paymentId,
paymentSystem: dto.paymentSystem,
);
await isar.writeTxn(
() => isar.subscriptionPlanModels.put(updatedModel),
);
return true;
Future<List<SubscriptionPlanModel>> getAllPlans() async {
// TODO: Implement with Drift
return [];
}
}
}

View file

@ -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;
}
}

View file

@ -53,43 +53,37 @@ class AdminAnalyticsApiV2 {
}
// Get basic statistics
final userCount = await backend_main.isar.userModels.count();
final userCount = await backend_main.database.userDao.countUsers();
final cardCount = await backend_main.isar.gameCardModels.count();
final cardCount = await backend_main.database.packDao.countCards();
final packCount = await backend_main.isar.cardPackModels.count();
final packCount = await backend_main.database.packDao.countPacks();
// Get all packs and filter in memory
final packsQuery = backend_main.isar.cardPackModels.where();
// Note: Using a different approach since findAll may not be available
final enabledPackCount = 0; // Temporary placeholder
final enabledPacks = await backend_main.database.packDao.getAllPacks(enabledOnly: true);
final enabledPackCount = enabledPacks.length;
final paymentCount = await backend_main.isar.paymentModels.count();
final paymentCount = await backend_main.database.paymentDao.countAllPayments();
// Get recent users (last 10)
final allUsers = await backend_main.isar
.txn(() async => backend_main.isar.userModels.where().findAll());
final sortedUsers = allUsers
..sort((a, b) => (b.id ?? 0).compareTo(a.id ?? 0));
final recentUsers = sortedUsers
.take(10)
final allUsers = await backend_main.database.userDao.getAllUsers(limit: 10);
final recentUsers = allUsers
.map((u) => {
'id': u.id,
'name': u.name,
'email': u.email,
'createdAt': DateTime(1999).toIso8601String(),
'createdAt': u.createdAt.toIso8601String(),
})
.toList();
// Get top packs by user count (mock data for now)
final allPacks = await backend_main.isar
.txn(() async => backend_main.isar.cardPackModels.where().findAll());
final topPacks = allPacks
.take(5)
final allPacks = await backend_main.database.packDao.getAllPacks();
final topPacksList = allPacks.take(5);
final topPacks = topPacksList
.map((p) => {
'id': p.id,
'title': p.title,
'cards': p.cards.length,
'cards': 0, // TODO: get card count for pack
'enabled': p.enabled,
})
.toList();

View file

@ -1,299 +1,203 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/api/authorize/acl_types.dart';
import 'package:mnemo_cards_backend/api/authorize/access_service.dart';
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
import 'package:mnemo_cards_backend/main.dart' as backend_main;
import 'package:mnemo_cards_backend/packs/card_model_extension.dart';
import 'package:mnemo_cards_backend/packs/card_dto_extension.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:injectable/injectable.dart';
import 'package:drift/drift.dart' as drift;
part 'admin_cards_api_v2.g.dart';
/// Admin endpoints for card management in API v2.
@lazySingleton
@injectable
class AdminCardsApiV2 {
AdminCardsApiV2();
final AppDatabase _db;
Response _json(
Object? data, {
int statusCode = 200,
Map<String, String> headers = const {},
}) {
return Response(
statusCode,
body: data == null ? null : jsonEncode(data),
headers: {
'Content-Type': 'application/json',
...headers,
},
);
}
const AdminCardsApiV2(this._db);
Response _badRequest(String message) => Response.badRequest(
body: jsonEncode({'error': 'Bad Request', 'message': message}),
headers: {'Content-Type': 'application/json'},
);
@Route.get('/cards')
Future<Response> getAllCards(Request request) async {
try {
final packId = request.url.queryParameters['packId'];
final limit = int.tryParse(request.url.queryParameters['limit'] ?? '50') ?? 50;
final offset = int.tryParse(request.url.queryParameters['offset'] ?? '0') ?? 0;
Response _notFound([String? message]) => Response.notFound(
jsonEncode({
'error': 'Not Found',
'message': message ?? 'Resource not found',
final cards = packId != null
? await _db.packDao.getPackCards(int.parse(packId))
: await _db.packDao.getAllCards(limit: limit, offset: offset);
final total = packId != null
? cards.length
: await _db.packDao.countCards();
return Response.ok(
json.encode({
'cards': cards.map((card) => {
'id': card.id,
'packId': card.packId,
'original': card.original,
'translation': card.translation,
'mnemo': card.mnemo,
'image': card.image,
'back': card.back,
'transcription': card.transcription,
'createdAt': card.createdAt.toIso8601String(),
}).toList(),
'total': total,
}),
headers: {'Content-Type': 'application/json'},
);
Response _internalServerError([String? message]) => Response(
500,
body: jsonEncode({
'error': 'Internal Server Error',
'message': message ?? 'An error occurred',
}),
} catch (e) {
return Response.internalServerError(
body: json.encode({'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
Future<Response> _ensureAdmin(Request request) async {
try {
await request.access!
.requireAdmin(AdminAction.access, user: request.user);
return Response.ok(null);
} on AccessDenied catch (e) {
return Response(e.status, body: e.message);
}
}
/// GET /api/v2/admin/cards
/// Get all cards with pagination and optional search
/// Query params: ?page=1&limit=20&search=term
@Route.get('/admin/cards')
Future<Response> getCards(Request request) async {
try {
final auth = await _ensureAdmin(request);
if (auth.statusCode != 200) {
return auth;
}
final queryParams = request.requestedUri.queryParameters;
// Parse pagination parameters
final page = int.tryParse(queryParams['page'] ?? '1') ?? 1;
final limit = int.tryParse(queryParams['limit'] ?? '20') ?? 20;
final search = queryParams['search']?.trim();
// Validate pagination
if (page < 1) {
return _badRequest('Page must be greater than 0');
}
if (limit < 1 || limit > 100) {
return _badRequest('Limit must be between 1 and 100');
}
// Get all cards from database
final allCards = await backend_main.isar
.txn(() async => backend_main.isar.gameCardModels.where().findAll());
// Apply search filter if provided
List<GameCardModel> filteredCards = allCards;
if (search != null && search.isNotEmpty) {
final searchLower = search.toLowerCase();
filteredCards = allCards.where((card) {
return card.original.toLowerCase().contains(searchLower) ||
card.translation.toLowerCase().contains(searchLower) ||
card.mnemo.toLowerCase().contains(searchLower) ||
(card.transcription?.toLowerCase().contains(searchLower) ??
false) ||
(card.transcriptionMnemo?.toLowerCase().contains(searchLower) ??
false);
}).toList();
}
// Apply pagination
final total = filteredCards.length;
final totalPages = (total / limit).ceil();
final offset = (page - 1) * limit;
final paginatedCards = filteredCards.skip(offset).take(limit).toList();
// Convert to DTOs
final cardDtos = paginatedCards.map((card) => card.toDto()).toList();
return _json({
'items': cardDtos.map((c) => c.toJson()).toList(),
'total': total,
'page': page,
'limit': limit,
'totalPages': totalPages,
});
} catch (e, s) {
print('Error in getCards: $e\n$s');
return _internalServerError(e.toString());
}
}
/// GET /api/v2/admin/cards/:id
/// Get a specific card by ID
@Route.get('/admin/cards/<cardId>')
@Route.get('/cards/<cardId>')
Future<Response> getCard(Request request, String cardId) async {
try {
final auth = await _ensureAdmin(request);
if (auth.statusCode != 200) {
return auth;
final id = int.tryParse(cardId);
if (id == null) {
return Response.badRequest(
body: json.encode({'error': 'Invalid card ID'}),
headers: {'Content-Type': 'application/json'},
);
}
final cardIdInt = int.tryParse(cardId);
if (cardIdInt == null) {
return _badRequest('Invalid card ID');
}
final card = await backend_main.isar.txn(() async {
return await backend_main.isar.gameCardModels.get(cardIdInt);
});
final card = await _db.packDao.getCardById(id);
if (card == null) {
return _notFound('Card not found');
return Response.notFound(
json.encode({'error': 'Card not found'}),
headers: {'Content-Type': 'application/json'},
);
}
return _json(card.toDto().toJson());
} catch (e, s) {
print('Error in getCard: $e\n$s');
return _internalServerError(e.toString());
return Response.ok(
json.encode({
'id': card.id,
'packId': card.packId,
'original': card.original,
'translation': card.translation,
'mnemo': card.mnemo,
'image': card.image,
'back': card.back,
'transcription': card.transcription,
'createdAt': card.createdAt.toIso8601String(),
'updatedAt': card.updatedAt?.toIso8601String(),
}),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
return Response.internalServerError(
body: json.encode({'error': e.toString()}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// POST /api/v2/admin/cards
/// Create or update a card
@Route.post('/admin/cards')
Future<Response> upsertCard(Request request) async {
@Route.post('/cards')
Future<Response> createCard(Request request) async {
try {
final auth = await _ensureAdmin(request);
if (auth.statusCode != 200) {
return auth;
final body = await request.readAsString();
final data = json.decode(body) as Map<String, dynamic>;
final companion = GameCardsCompanion.insert(
packId: data['packId'] as int,
original: data['original'] as String,
translation: data['translation'] as String,
mnemo: data['mnemo'] != null ? drift.Value(data['mnemo'] as String) : const drift.Value.absent(),
image: data['image'] != null ? drift.Value(data['image'] as String) : const drift.Value.absent(),
back: data['back'] != null ? drift.Value(data['back'] as String) : const drift.Value.absent(),
transcription: data['transcription'] != null ? drift.Value(data['transcription'] as String) : const drift.Value.absent(),
);
final cardId = await _db.packDao.createCard(companion);
return Response.ok(
json.encode({'success': true, 'cardId': cardId}),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
return Response.internalServerError(
body: json.encode({'error': e.toString(), 'success': false}),
headers: {'Content-Type': 'application/json'},
);
}
}
@Route.put('/cards/<cardId>')
Future<Response> updateCard(Request request, String cardId) async {
try {
final id = int.tryParse(cardId);
if (id == null) {
return Response.badRequest(
body: json.encode({'error': 'Invalid card ID'}),
headers: {'Content-Type': 'application/json'},
);
}
final body = await request.readAsString();
if (body.isEmpty) {
return _json(
{
'error': 'bad_request',
'message': 'Card payload is required',
},
statusCode: 400,
final data = json.decode(body) as Map<String, dynamic>;
final existing = await _db.packDao.getCardById(id);
if (existing == null) {
return Response.notFound(
json.encode({'error': 'Card not found'}),
headers: {'Content-Type': 'application/json'},
);
}
late final GameCardDto cardDto;
try {
cardDto =
GameCardDto.fromJson(jsonDecode(body) as Map<String, dynamic>);
} catch (_) {
return _json(
{
'error': 'bad_request',
'message': 'Invalid card payload',
},
statusCode: 400,
);
}
final updated = existing.copyWith(
original: data['original'] ?? existing.original,
translation: data['translation'] ?? existing.translation,
mnemo: data['mnemo'] ?? existing.mnemo,
image: data['image'] ?? existing.image,
back: data['back'] ?? existing.back,
transcription: data['transcription'] ?? existing.transcription,
updatedAt: DateTime.now(),
);
// Validate required fields
if (cardDto.original?.isEmpty ?? true) {
return _json(
{
'error': 'bad_request',
'message': 'Original text is required',
},
statusCode: 400,
);
}
await _db.packDao.updateCard(updated);
if (cardDto.translation?.isEmpty ?? true) {
return _json(
{
'error': 'bad_request',
'message': 'Translation is required',
},
statusCode: 400,
);
}
if (cardDto.mnemo?.isEmpty ?? true) {
return _json(
{
'error': 'bad_request',
'message': 'Mnemo is required',
},
statusCode: 400,
);
}
// Convert DTO to model
final cardModel = cardDto.toModel();
// Save to database
await backend_main.isar.writeTxn(() async {
await backend_main.isar.gameCardModels.put(cardModel);
});
// Get updated card with ID
final updatedCard = await backend_main.isar.txn(() async {
return await backend_main.isar.gameCardModels.get(cardModel.id!);
});
if (updatedCard == null) {
return _internalServerError('Failed to save card');
}
return _json({
'success': true,
'card': updatedCard.toDto().toJson(),
});
} catch (e, s) {
print('Error in upsertCard: $e\n$s');
return _internalServerError(e.toString());
return Response.ok(
json.encode({'success': true}),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
return Response.internalServerError(
body: json.encode({'error': e.toString(), 'success': false}),
headers: {'Content-Type': 'application/json'},
);
}
}
/// DELETE /api/v2/admin/cards/:id
/// Delete a card by ID
@Route.delete('/admin/cards/<cardId>')
@Route.delete('/cards/<cardId>')
Future<Response> deleteCard(Request request, String cardId) async {
try {
final auth = await _ensureAdmin(request);
if (auth.statusCode != 200) {
return auth;
final id = int.tryParse(cardId);
if (id == null) {
return Response.badRequest(
body: json.encode({'error': 'Invalid card ID'}),
headers: {'Content-Type': 'application/json'},
);
}
final cardIdInt = int.tryParse(cardId);
if (cardIdInt == null) {
return _badRequest('Invalid card ID');
}
await _db.packDao.deleteCard(id);
final card = await backend_main.isar.txn(() async {
return await backend_main.isar.gameCardModels.get(cardIdInt);
});
if (card == null) {
return _notFound('Card not found');
}
// Delete from database
await backend_main.isar.writeTxn(() async {
await backend_main.isar.gameCardModels.delete(cardIdInt);
});
return _json({
'success': true,
'message': 'Card deleted successfully',
});
} catch (e, s) {
print('Error in deleteCard: $e\n$s');
return _internalServerError(e.toString());
return Response.ok(
json.encode({'success': true}),
headers: {'Content-Type': 'application/json'},
);
} catch (e) {
return Response.internalServerError(
body: json.encode({'error': e.toString(), 'success': false}),
headers: {'Content-Type': 'application/json'},
);
}
}
Router get router => _$AdminCardsApiV2Router(this);
}
Handler get handler => _$AdminCardsApiV2Router(this);
}

View 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);
}

View file

@ -10,22 +10,27 @@ Router _$AdminCardsApiV2Router(AdminCardsApiV2 service) {
final router = Router();
router.add(
'GET',
r'/admin/cards',
service.getCards,
r'/cards',
service.getAllCards,
);
router.add(
'GET',
r'/admin/cards/<cardId>',
r'/cards/<cardId>',
service.getCard,
);
router.add(
'POST',
r'/admin/cards',
service.upsertCard,
r'/cards',
service.createCard,
);
router.add(
'PUT',
r'/cards/<cardId>',
service.updateCard,
);
router.add(
'DELETE',
r'/admin/cards/<cardId>',
r'/cards/<cardId>',
service.deleteCard,
);
return router;

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -6,6 +6,7 @@ import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
import 'package:mnemo_cards_backend/api/v2/jwt_service.dart';
import 'package:mnemo_cards_backend/api/user/google_api.dart';
import 'package:mnemo_cards_backend/auth/telegram_auth_code_service.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/user/telegram.dart';
import 'package:mnemo_cards_backend/user/user_manager.dart';
import 'package:mnemo_cards_backend/user/user_model.dart';
@ -20,12 +21,14 @@ part 'auth_api_v2.g.dart';
/// Implements OAuth2/JWT Bearer token authentication
@lazySingleton
class AuthApiV2 {
final AppDatabase _db;
final UserManager _userManager;
final GoogleApi _googleApi;
final JwtService _jwtService;
final TelegramAuthCodeService _telegramAuthCodeService;
AuthApiV2(
this._db,
this._userManager,
this._googleApi,
this._jwtService,

View file

@ -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;
}

View file

@ -1,10 +1,10 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/main.dart' as main;
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
/// Service for JWT token generation and verification
@ -12,12 +12,18 @@ import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
/// Generates access tokens (short-lived) and refresh tokens (long-lived)
@lazySingleton
class JwtService {
// In production, use environment variables or secure key management
static const String _secretKey = 'your-secret-key-change-in-production';
// Read secrets from environment variables
static final String _jwtSecret = Platform.environment['JWT_SECRET'] ??
'dev-jwt-secret-change-me-in-production';
static final String _jwtRefreshSecret = Platform.environment['JWT_REFRESH_SECRET'] ??
'dev-refresh-secret-change-me-in-production';
static const int _accessTokenExpirySeconds = 3600; // 1 hour
static const int _refreshTokenExpirySeconds = 2592000; // 30 days
JwtService();
final AppDatabase _db;
JwtService(this._db);
/// Generate access and refresh tokens for a user
Future<JwtTokens> generateTokens(UserModel user) async {
@ -141,9 +147,13 @@ class JwtService {
final headerB64 = base64UrlEncode(utf8.encode(jsonEncode(header)));
final payloadB64 = base64UrlEncode(utf8.encode(jsonEncode(payload)));
// Create signature (simplified - in production use proper HMAC)
// Use different secrets for access and refresh tokens
final isRefreshToken = payload['type'] == 'refresh';
final secret = isRefreshToken ? _jwtRefreshSecret : _jwtSecret;
// Create signature with HMAC-SHA256
final signatureInput = '$headerB64.$payloadB64';
final signature = _hmacSha256(utf8.encode(signatureInput), _secretKey);
final signature = _hmacSha256(utf8.encode(signatureInput), secret);
final signatureB64 = base64UrlEncode(signature);
return '$headerB64.$payloadB64.$signatureB64';
@ -162,10 +172,17 @@ class JwtService {
final payloadB64 = parts[1];
final signatureB64 = parts[2];
// Verify signature
// Decode payload first to determine token type
final payloadJson = utf8.decode(base64Url.decode(payloadB64));
final payload = jsonDecode(payloadJson) as Map<String, dynamic>;
// Use different secrets for access and refresh tokens
final isRefreshToken = payload['type'] == 'refresh';
final secret = isRefreshToken ? _jwtRefreshSecret : _jwtSecret;
// Verify signature with appropriate secret
final signatureInput = '$headerB64.$payloadB64';
final expectedSignature =
_hmacSha256(utf8.encode(signatureInput), _secretKey);
final expectedSignature = _hmacSha256(utf8.encode(signatureInput), secret);
final expectedSignatureB64 = base64UrlEncode(expectedSignature);
if (signatureB64 != expectedSignatureB64) {
@ -174,9 +191,7 @@ class JwtService {
return null; // Invalid signature
}
// Decode payload
final payloadJson = utf8.decode(base64Url.decode(payloadB64));
return jsonDecode(payloadJson) as Map<String, dynamic>;
return payload;
} catch (e) {
lastVerificationError = 'Parse exception: $e';
return null;
@ -218,32 +233,27 @@ class JwtService {
DateTime createdAt,
DateTime expiresAt,
) async {
await main.isar.writeTxn(() async {
await _db.transaction(() async {
// Delete old token if exists (shouldn't happen due to unique index, but be safe)
final existing = await main.isar.refreshTokenModels
.filter()
.jtiEqualTo(jti)
.findFirst();
final existing = await _db.userDao.getRefreshTokenByJti(jti);
if (existing != null) {
await main.isar.refreshTokenModels.delete(existing.id!);
await _db.userDao.revokeRefreshToken(existing.id);
}
// Store new token
await main.isar.refreshTokenModels.put(
RefreshTokenModel(
await _db.userDao.createRefreshToken(
RefreshTokensCompanion.insert(
jti: jti,
userId: userId,
createdAt: createdAt,
expiresAt: expiresAt,
isBlacklisted: false,
expiresAt: expiresAt, // required field - raw DateTime
// createdAt and isBlacklisted use defaults from table
),
);
});
}
Future<bool> _isTokenBlacklisted(String jti) async {
final token =
await main.isar.refreshTokenModels.filter().jtiEqualTo(jti).findFirst();
final token = await _db.userDao.getRefreshTokenByJti(jti);
if (token == null) {
return true; // Token not found, consider it invalid
@ -259,38 +269,12 @@ class JwtService {
/// Blacklist a refresh token (for logout)
Future<void> blacklistRefreshToken(String jti) async {
await main.isar.writeTxn(() async {
final token = await main.isar.refreshTokenModels
.filter()
.jtiEqualTo(jti)
.findFirst();
if (token != null) {
await main.isar.refreshTokenModels.put(
token.copyWith(isBlacklisted: true),
);
}
});
await _db.userDao.revokeRefreshTokenByJti(jti);
}
/// Clean up expired tokens (should be called periodically)
Future<void> cleanupExpiredTokens() async {
await main.isar.writeTxn(() async {
final now = DateTime.now();
final expired = await main.isar.refreshTokenModels
.filter()
.expiresAtLessThan(now)
.findAll();
if (expired.isNotEmpty) {
final ids = expired
.map((t) => t.id)
.where((id) => id != null)
.cast<Id>()
.toList();
if (ids.isNotEmpty) {
await main.isar.refreshTokenModels.deleteAll(ids);
}
}
});
await _db.userDao.deleteExpiredRefreshTokens();
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -2,21 +2,19 @@ import 'dart:convert';
import 'dart:developer';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
import 'package:mnemo_cards_backend/main.dart' as backend_main;
import 'package:mnemo_cards_backend/tasks/task_manager.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../../main.dart';
part 'tasks_api_v2.g.dart';
/// API v2 endpoints for user tasks management
@lazySingleton
class TasksApiV2 {
const TasksApiV2();
final TaskManager _taskManager;
const TasksApiV2(this._taskManager);
Response _json(
Object? data, {
@ -82,40 +80,44 @@ class TasksApiV2 {
final limit = int.tryParse(queryParams['limit'] ?? '50') ?? 50;
final offset = int.tryParse(queryParams['offset'] ?? '0') ?? 0;
// Build query
var builder = isar.userTaskModels.where().filter();
// Get tasks using TaskManager
final tasks = await _taskManager.getUserTasks(
userId,
type: type,
difficulty: difficulty,
status: status,
tags: tags,
limit: limit,
offset: offset,
);
if (type != null) {
builder = builder.typeEqualTo(type);
}
// Convert to JSON format
final tasksJson = tasks.map((t) => {
'id': t.id,
'title': t.title,
'description': t.description,
'type': t.type,
'difficulty': t.difficulty,
'status': t.status,
'rewards': t.rewards,
'createdAt': t.createdAt.toIso8601String(),
'expiresAt': t.expiresAt.toIso8601String(),
'completedAt': t.completedAt?.toIso8601String(),
'proofUrl': t.proofUrl,
'instructions': t.instructions,
'tags': t.tags,
'imageUrl': t.imageUrl,
}).toList();
if (difficulty != null) {
builder = builder.difficultyEqualTo(difficulty);
}
if (status != null) {
builder = builder.statusEqualTo(status);
}
if (tags != null && tags.isNotEmpty) {
// For simplicity, we'll check if any tag matches
// In a more complex scenario, you might want to use a more sophisticated query
final allTasks = await builder.idIsNotNull().findAll();
final filteredTasks = allTasks.where((task) {
return tags.any((tag) => task.tags.contains(tag));
}).toList();
final tasks = filteredTasks.skip(offset).take(limit).toList();
return _json({'tasks': tasks.map((t) => t.toJson()).toList()});
}
final tasks =
await builder.idIsNotNull().offset(offset).limit(limit).findAll();
return _json({'tasks': tasks.map((t) => t.toJson()).toList()});
return _json({
'tasks': tasksJson,
'total': tasks.length, // TODO: Get actual total count
'limit': limit,
'offset': offset,
});
} catch (e) {
log('Error fetching tasks: $e');
return _serverError('Failed to fetch tasks');
return _serverError('Failed to fetch tasks: $e');
}
}
@ -129,13 +131,30 @@ class TasksApiV2 {
final taskIdInt = int.tryParse(taskId);
if (taskIdInt == null) return _badRequest('Invalid task ID');
final task = await isar.userTaskModels.get(taskIdInt);
final task = await _taskManager.getUserTask(userId, taskIdInt);
if (task == null) return _notFound('Task not found');
return _json({'task': task.toJson()});
return _json({
'task': {
'id': task.id,
'title': task.title,
'description': task.description,
'type': task.type,
'difficulty': task.difficulty,
'status': task.status,
'rewards': task.rewards,
'createdAt': task.createdAt.toIso8601String(),
'expiresAt': task.expiresAt.toIso8601String(),
'completedAt': task.completedAt?.toIso8601String(),
'proofUrl': task.proofUrl,
'instructions': task.instructions,
'tags': task.tags,
'imageUrl': task.imageUrl,
}
});
} catch (e) {
log('Error fetching task: $e');
return _serverError('Failed to fetch task');
return _serverError('Failed to fetch task: $e');
}
}
@ -149,28 +168,13 @@ class TasksApiV2 {
final taskIdInt = int.tryParse(taskId);
if (taskIdInt == null) return _badRequest('Invalid task ID');
final task = await isar.userTaskModels.get(taskIdInt);
if (task == null) return _notFound('Task not found');
// Start task using TaskManager
await _taskManager.startTask(userId, taskIdInt);
if (!task.isActive) return _badRequest('Task is not available');
// Update task status to in_progress
final updatedTask = task.copyWith(status: 'in_progress');
await isar.writeTxn(() async {
await isar.userTaskModels.put(updatedTask);
});
// Update user progress
await _updateUserProgress(
userId.toString(), taskId.toString(), 'in_progress');
return _json({
'task': updatedTask.toJson(),
'message': 'Task started successfully'
});
return _json({'success': true, 'message': 'Task started successfully'});
} catch (e) {
log('Error starting task: $e');
return _serverError('Failed to start task');
return _serverError('Failed to start task: $e');
}
}
@ -184,193 +188,51 @@ class TasksApiV2 {
final taskIdInt = int.tryParse(taskId);
if (taskIdInt == null) return _badRequest('Invalid task ID');
final body = await request.readAsString();
final data = jsonDecode(body) as Map<String, dynamic>;
// Complete task using TaskManager
await _taskManager.completeTask(userId, taskIdInt);
final proofUrl = data['proofUrl'] as String?;
final notes = data['notes'] as String?;
final task = await isar.userTaskModels.get(taskIdInt);
if (task == null) return _notFound('Task not found');
if (task.status != 'in_progress') {
return _badRequest('Task is not in progress');
}
// Update task status to completed
final now = DateTime.now();
final updatedTask = task.copyWith(
status: 'completed',
completedAt: now,
proofUrl: proofUrl,
);
await isar.writeTxn(() async {
await isar.userTaskModels.put(updatedTask);
// Create task result record
final result = UserTaskResultModel(
taskId: taskId.toString(),
userId: userId.toString(),
status: 'completed',
submittedAt: now,
proofUrl: proofUrl,
notes: notes,
);
await isar.userTaskResultModels.put(result);
});
// Update user progress with rewards
await _updateUserProgress(
userId.toString(), taskId.toString(), 'completed',
rewards: updatedTask.rewards);
return _json({
'task': updatedTask.toJson(),
'message': 'Task completed successfully',
'rewards': updatedTask.rewards.map((r) => r.toJson()).toList(),
});
return _json({'success': true, 'message': 'Task completed successfully'});
} catch (e) {
log('Error completing task: $e');
return _serverError('Failed to complete task');
return _serverError('Failed to complete task: $e');
}
}
/// GET /api/v2/users/me/tasks/progress - Get user task progress
@Route.get('/users/me/tasks/progress')
Future<Response> getUserProgress(Request request) async {
Future<Response> getUserTaskProgress(Request request) async {
final userId = request.user?.id;
if (userId == null) return _unauthorized();
try {
final progress = await isar.userTaskProgressModels
.filter()
.userIdEqualTo(userId.toString())
.findFirst();
if (progress == null) {
// Create initial progress if it doesn't exist
final newProgress = UserTaskProgressModel(
userId: userId.toString(),
taskStatusesJson: '{}',
completedTasksJson: '{}',
totalXp: 0,
totalCoins: 0,
achievementsJson: '[]',
lastUpdated: DateTime.now(),
);
await isar.writeTxn(() async {
await isar.userTaskProgressModels.put(newProgress);
});
return _json({'progress': newProgress.toJson()});
}
return _json({'progress': progress.toJson()});
} catch (e) {
log('Error fetching user progress: $e');
return _serverError('Failed to fetch user progress');
}
}
/// GET /api/v2/tasks/categories - Get available task categories and filters
@Route.get('/tasks/categories')
Future<Response> getTaskCategories(Request request) async {
final userId = request.user?.id;
if (userId == null) return _unauthorized();
try {
// Get all tasks to extract categories and filters
final tasks = await isar.userTaskModels.where().findAll();
final types = tasks.map((t) => t.type).toSet().toList();
final difficulties = tasks.map((t) => t.difficulty).toSet().toList();
final allTags = tasks.expand((t) => t.tags).toSet().toList();
final progress = await _taskManager.getUserTaskProgress(userId);
return _json({
'categories': {
'types': types,
'difficulties': difficulties,
'tags': allTags,
}
'progress': progress.map((p) => {
'taskId': p.taskId,
'progress': p.progress,
'startedAt': p.startedAt.toIso8601String(),
'updatedAt': p.updatedAt.toIso8601String(),
}).toList()
});
} catch (e) {
log('Error fetching task progress: $e');
return _serverError('Failed to fetch task progress: $e');
}
}
/// GET /api/v2/tasks/categories - Get task categories
@Route.get('/tasks/categories')
Future<Response> getTaskCategories(Request request) async {
try {
final categories = await _taskManager.getTaskCategories();
return _json({'categories': categories});
} catch (e) {
log('Error fetching task categories: $e');
return _serverError('Failed to fetch task categories');
return _serverError('Failed to fetch task categories: $e');
}
}
/// Helper method to update user progress
Future<void> _updateUserProgress(
String userId,
String taskId,
String status, {
List<TaskRewardModel>? rewards,
}) async {
final progress = await isar.userTaskProgressModels
.filter()
.userIdEqualTo(userId)
.findFirst();
if (progress == null) return;
final taskStatuses = Map<String, String>.from(progress.taskStatuses);
taskStatuses[taskId] = status;
int totalXp = progress.totalXp;
int totalCoins = progress.totalCoins;
final achievements = List<String>.from(progress.achievements);
if (status == 'completed' && rewards != null) {
final completedTasks =
Map<String, DateTime>.from(progress.completedTasks);
completedTasks[taskId] = DateTime.now();
for (final reward in rewards) {
switch (reward.type) {
case 'xp':
totalXp += reward.amount;
break;
case 'coins':
totalCoins += reward.amount;
break;
case 'achievement':
if (reward.achievementId != null &&
!achievements.contains(reward.achievementId)) {
achievements.add(reward.achievementId!);
}
break;
}
}
final updatedProgress = progress.copyWith(
taskStatusesJson:
UserTaskProgressModel.taskStatusesToJson(taskStatuses),
completedTasksJson:
UserTaskProgressModel.completedTasksToJson(completedTasks),
totalXp: totalXp,
totalCoins: totalCoins,
achievementsJson:
UserTaskProgressModel.achievementsToJson(achievements),
lastUpdated: DateTime.now(),
);
await isar.writeTxn(() async {
await isar.userTaskProgressModels.put(updatedProgress);
});
} else {
final updatedProgress = progress.copyWith(
taskStatusesJson:
UserTaskProgressModel.taskStatusesToJson(taskStatuses),
lastUpdated: DateTime.now(),
);
await isar.writeTxn(() async {
await isar.userTaskProgressModels.put(updatedProgress);
});
}
}
Router get router => _$TasksApiV2Router(this);
}
Handler get handler => _$TasksApiV2Router(this);
}

View file

@ -31,7 +31,7 @@ Router _$TasksApiV2Router(TasksApiV2 service) {
router.add(
'GET',
r'/users/me/tasks/progress',
service.getUserProgress,
service.getUserTaskProgress,
);
router.add(
'GET',

View 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);
}
}

View 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();
}
}

View 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;
}

View 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;
}
}

View 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;
}

View 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();
}
}

View 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;
}

View 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();
}
}

View 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;
}

View 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();
}
}

View 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;
}

View 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();
}
}

View file

@ -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;
}

View 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();
}
}

View file

@ -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;
}

View 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);
}
}

View 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> {}

View 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);
}
}

View 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;
}

View 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});
}

View 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;
}

View 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');
}
}

File diff suppressed because it is too large Load diff

View 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

View 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()();
}

View 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

View 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

View 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

View 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

View 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};
}

View 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)();
}

View 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

View 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

View 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)();
}

View 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

View 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

View file

@ -1,15 +1,14 @@
import 'dart:developer';
import 'dart:io';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/api/mnemo_shelf.dart';
import 'package:mnemo_cards_backend/cron/add_free_packs.dart';
import 'package:mnemo_cards_backend/cron/backup.dart';
import 'package:mnemo_cards_backend/cron/cron_executor.dart';
import 'package:mnemo_cards_backend/cron/generate_promocodes.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
import 'package:mnemo_cards_backend/user/user_manager.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/tests/test_manager.dart';
import 'api/di/injector.dart';
@ -22,44 +21,56 @@ import 'cron/test_generator.dart';
import 'cron/update_online_users.dart';
import 'packs/free_packs_distributor.dart';
late Isar isar;
late AppDatabase database;
late final String WORK_DIR;
Future<Isar> _initIsar(
String dir,
bool debugMode,
) async {
/// Инициализация подключения к PostgreSQL
Future<AppDatabase> _initDatabase() async {
var attempt = 0;
while (attempt < 5) {
print('Attempt $attempt to initialize Isar');
try {
return isar = await IsarConnector().connect(
dir: dir,
name: 'db',
inspector: debugMode,
);
} catch (e, s) {
print('Error initializing Isar: $e');
log('Error initializing Isar: $e', error: e, stackTrace: s);
const maxAttempts = 5;
while (attempt < maxAttempts) {
print('Attempt ${attempt + 1}/$maxAttempts to connect to PostgreSQL...');
try {
// Создать подключение из environment variables
final db = AppDatabase.fromEnvironment();
// Проверить подключение
await db.customSelect('SELECT 1').getSingle();
print('✅ Successfully connected to PostgreSQL');
return database = db;
} catch (e, s) {
print('❌ Error connecting to PostgreSQL: $e');
log('Error connecting to PostgreSQL: $e', error: e, stackTrace: s);
attempt++;
if (attempt < maxAttempts) {
final delay = Duration(seconds: attempt * 5);
print('Retrying in ${delay.inSeconds} seconds...');
await Future.delayed(delay);
}
}
}
await Future.delayed(Duration(seconds: attempt * 10));
attempt++;
}
throw Exception('Failed to initialize Isar');
throw Exception('Failed to connect to PostgreSQL after $maxAttempts attempts');
}
void main() async {
// WidgetsFlutterBinding.ensureInitialized();
// final dir = (await getApplicationDocumentsDirectory()).path;
// Чтение переменных окружения вместо CLI аргументов
final dir = Platform.environment['ISAR_DIR'] ?? 'isar';
final backupDir = Platform.environment['BACKUP_DIR'] ??
'../mnemo_cards_telegram_bot/backups/';
print('🚀 Starting Mnemo Cards Backend...');
// Читаем environment variables
final backupDir = Platform.environment['BACKUP_DIR'] ?? '../backups/';
WORK_DIR = Platform.environment['WORK_DIR'] ?? '/root/mnemo_cards_backend';
final debugMode = Platform.environment['DEBUG'] == 'true' ||
Platform.environment['DEBUG'] == '1';
print('📂 Working directory: $WORK_DIR');
print('🐛 Debug mode: $debugMode');
print(
'running server in ${(await Process.run('pwd', [], runInShell: true)).stdout}',
);
@ -75,45 +86,61 @@ void main() async {
);
print(pythonInit.stdout);
print(pythonInit.stderr);
// if (pythonInit.stderr != null) {
// throw Exception('Not inited');
// }
} else {
log('\n\n*******\nRUSTORE IS DISABLED\n******\n\n', level: 1000);
}
// await Isar.initializeIsarCore(download: true);
final debugMode = Platform.environment['DEBUG'] == 'true' ||
Platform.environment['DEBUG'] == '1';
_initIsar(dir, debugMode).then((isar) async {
getIt.registerSingleton<Isar>(isar);
configureDependencies();
await getIt<MnemoShelf>().initV2();
// ignore: unawaited_futures
CronManager([
DeleteOldArchives(),
CheckAdminsTask(),
TestGeneratorTask(getIt.get<TestManager>()),
getIt.get<CheckPaymentTask>(),
AddFreePacks(getIt.get<FreePacksDistributor>()),
Backup(backupDir),
GeneratePromocodes(),
DiscountCampaignTask(getIt.get<DiscountsManager>()),
UpdateOnlineUsersTask(getIt.get<UserManager>()),
TasksSeederTask(),
]).init();
});
// Инициализация PostgreSQL
try {
await _initDatabase();
// Регистрация в DI
getIt.registerSingleton<AppDatabase>(database);
// Настройка остальных зависимостей
configureDependencies();
// Запуск API сервера
print('🌐 Starting API server...');
await getIt<MnemoShelf>().initV2();
// Запуск cron jobs
print('⏰ Starting cron jobs...');
// ignore: unawaited_futures
CronManager([
DeleteOldArchives(),
CheckAdminsTask(),
// TestGeneratorTask(getIt.get<TestManager>()), // TODO: enable after TestManager migration
getIt.get<CheckPaymentTask>(),
AddFreePacks(getIt.get<FreePacksDistributor>()),
Backup(backupDir),
GeneratePromocodes(),
DiscountCampaignTask(getIt.get<DiscountsManager>()),
UpdateOnlineUsersTask(getIt.get<UserManager>()),
TasksSeederTask(),
]).init();
print('✅ Backend started successfully!');
} catch (e, s) {
print('❌ Fatal error starting backend: $e');
log('Fatal error starting backend: $e', error: e, stackTrace: s);
exit(1);
}
// Обработка SIGTERM для graceful shutdown
ProcessSignal.sigterm.watch().listen((signal) async {
print('🛑 Received SIGTERM, shutting down gracefully...');
try {
await isar.close();
// Закрыть подключение к БД
await database.close();
print('✅ Database connection closed');
} catch (e, s) {
log('Error closing Isar: $e', error: e, stackTrace: s);
print('Error closing Isar: $e');
print('❌ Error closing database: $e');
log('Error closing database: $e', error: e, stackTrace: s);
}
exit(0);
});
}

View 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
);
}
}

View file

@ -1,459 +1,135 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'package:image/image.dart';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:archive/archive.dart';
import 'package:mnemo_cards_backend/packs/card_dto_extension.dart';
import 'package:mnemo_cards_backend/packs/card_pack_model_extension.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart' hide VoiceModel;
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import '../main.dart';
import 'pack_dto_converter.dart';
import 'pack_manager_extensions.dart';
import 'card_pack_drift_extension.dart';
@lazySingleton
class PackManager {
final AppDatabase _db;
final PackDtoConverter packDtoConverter;
PackManager(this.packDtoConverter);
PackManager(this._db, this.packDtoConverter);
Future<List<CardPackPreviewDto>> listPacksPreviews(
UserModel? userModel,
Map<String, String>? params,
) async {
final models = await isar.cardPackModels
.filter()
.optional(
userModel?.admin != true,
(q) => q.enabledEqualTo(true),
)
.findAll();
models.sort((p, n) => p.order.compareTo(n.order));
return (await Future.wait(models.map(
(model) async {
final dto =
await packDtoConverter.toCardPackPreviewDto(model, userModel);
if (!model.enabled) {
// Get packs from Drift database
final packs = await _db.packDao.getAllPacks(
enabledOnly: true,
orderByField: 'order',
);
return (await Future.wait(packs.map(
(pack) async {
final dto = await pack.toPreviewDto(userModel);
if (!pack.enabled) {
return dto.copyWith(subtitle: 'DISABLED ${dto.subtitle}');
}
return dto;
},
)))
..sort((prev, next) {
final bothAvailable = prev.isAvailable && next.isAvailable;
if (bothAvailable) {
return 0;
}
final prevAd = canOpenForAd(int.tryParse(prev.id));
final nextAd = canOpenForAd(int.tryParse(next.id));
final orderAds = prevAd == nextAd
? 0
: prevAd
? -1
: 1;
final orderAvailability = prev.isAvailable == next.isAvailable
? 0
: prev.isAvailable
? -1
: 1;
return orderAvailability == 0 ? orderAds : orderAvailability;
});
.toList();
}
//no images
Future<List<CardPackAction>> listPacksActions(
Map<String, String?>? packsData,
UserModel user,
) async {
final availablePacks = await isar.txn(() async {
await user.subscriptionModel.load();
if (user.subscriptionModel.value?.features
.contains(SubscriptionFeatureEnum.packs) ==
true) {
return isar.cardPackModels.filter().enabledEqualTo(true).findAll();
} else {
return user.packs.filter().enabledEqualTo(true).findAll();
}
});
List<CardPackAction> response = [];
for (final pack in availablePacks) {
final expectedUserVersion = pack.version;
final userPackVersion = packsData?[pack.id.toString()];
if (userPackVersion == expectedUserVersion) {
//last version
continue;
} else {
// needs update
response.add(
CardPackAction(
id: pack.id.toString(),
action: PackAction.update,
),
);
}
}
//if front has packs we dont have - action delete
for (final id in packsData!.keys.where(
(id) =>
id.isNotEmpty &&
availablePacks.where((p) => p.id.toString() == id.toString()).isEmpty,
)) {
response.add(
CardPackAction(
id: id,
action: PackAction.delete,
),
);
}
return response;
Future<CardPack?> getPack(int id) async {
return await _db.packDao.getPackById(id);
}
// Future<void> addPack(CardPackModel model, String productId) async {
// await isar.writeTxn(
// () async {
// if (model.id != null && model.id! < 0) {
// model = model.copyWith(id: null);
// }
// await isar.cardPackModels.put(model);
// final cards = cardPackDto.cards.map((e) => e.toModel()).toList();
// isar.gameCardModels.putAll(cards);
// await model.cards
// ..addAll(cards)
// ..save();
// },
// );
// }
Future<bool> deletePack(String idString) async {
return isar.writeTxn(() async {
final id = int.tryParse(idString);
if (id == null) return false;
final model = await isar.cardPackModels.get(id);
if (model == null) return false;
return await isar.cardPackModels.delete(id);
});
Future<List<GameCard>> getCards(int packId) async {
return await _db.packDao.getPackCards(packId);
}
Future<bool> deleteCard(String idString) async {
return isar.writeTxn(() async {
final id = int.tryParse(idString);
if (id == null) return false;
final model = await isar.gameCardModels.get(id);
if (model == null) return false;
await model.packs.load();
if (model.packs.isNotEmpty) return false;
return await isar.gameCardModels.delete(id);
});
Future<GameCard?> getCard(int id) async {
return await _db.packDao.getCardById(id);
}
Future<void> addCard(GameCardDto dto) async {
await isar.writeTxn(() async {
GameCardModel model;
if (dto.id < 0) {
print('Creating new card ${dto.original}');
model = dto.toModel().copyWith(id: null);
} else {
final existingModel = await isar.gameCardModels.get(dto.id);
if (existingModel != null) {
print('Editing card ${dto.id} ${dto.original}');
model = existingModel.copyWith(
image: dto.image ?? existingModel.image,
mnemo: dto.mnemo ?? existingModel.mnemo,
original: dto.original ?? existingModel.original,
translation: dto.translation ?? existingModel.translation,
transcription: dto.transcription ?? existingModel.transcription,
imageBack: dto.imageBack ?? existingModel.imageBack,
transcriptionMnemo:
dto.transcriptionMnemo ?? existingModel.transcriptionMnemo,
);
} else {
print('Creating new card with id ${dto.id} ${dto.original}');
model = dto.toModel();
}
}
final cardId = await isar.gameCardModels.put(model);
if (model.image.length > 100) {
print('Adding base64 image for card ${dto.id} ${dto.original}');
//base64 image
try {
final data = base64Decode(model.image);
final image = decodeImage(data)!;
final fileName = '${cardId}_${dto.original}.png';
final resizedImage = copyResize(image, width: 1024, height: 1024);
final png = encodePng(resizedImage, level: 9);
final imageFile = File('${assetsDirectory.path}/cards/$fileName');
for (final imageSize in _ImageSize.values) {
final resizedFile = _getResizedFile(fileName, 'cards', imageSize);
try {
if (await resizedFile.exists()) {
print(
'Removing old image cache for ${imageSize.name}: ${resizedFile.path}');
await resizedFile.delete();
}
} catch (_) {
print('Error deleting resized image ${resizedFile.path}');
}
}
imageFile.writeAsBytesSync(png, flush: true);
print('Card image saved ${imageFile.path}');
model = model.copyWith(image: fileName);
} catch (_) {
throw Exception('Some exception');
}
}
final id = await isar.gameCardModels.put(model);
final cardPackModel = (await isar.gameCardModels.get(id))!;
await cardPackModel.packs.load();
final packs = cardPackModel.packs.toList();
for (final pack in packs) {
await isar.cardPackModels.put(
pack.copyWith(version: pack.version.incVersion),
);
}
});
}
Future<GetCardPackResponse?> getPackOrBuy(
String packId,
UserModel? userModel,
) async {
final model = await _fetchPackModel(packId);
if (userModel?.packs.contains(packId) != true) {
return packDtoConverter.toCardPackBuyDto(model, userModel);
}
return packDtoConverter.toDto(model);
}
Future<CardPackModel?> getPackModelIfAvailable(
UserModel userModel,
String packId,
) async {
final id = int.parse(packId);
userModel.subscriptionModel.load();
final allPacksAvailable =
userModel.subscriptionModel.value?.features.contains(
SubscriptionFeatureEnum.packs,
) ==
true;
if (allPacksAvailable) {
final model = await isar.txn(() => isar.cardPackModels.get(id));
if (model?.enabled == true) {
return model;
}
return null;
}
final model = await userModel.packs
.filter()
.enabledEqualTo(true)
.idEqualTo(id)
.findFirst();
return model;
}
Future<GetCardPackResponse?> getPack(
String packId,
UserModel? userModel,
) async {
// Preview pack (id=10) доступен без авторизации
if (packId == '10') {
final model = await _fetchPackModel(packId);
if (model.enabled) {
return packDtoConverter.toDto(model);
}
return null;
}
if (userModel == null) {
return null;
}
final model = await getPackModelIfAvailable(userModel, packId);
if (model == null) {
return null;
}
return packDtoConverter.toDto(model);
}
Future<EditCardPackDto?> getEditPack(String packId) async {
final id = int.tryParse(packId);
if (id == null) return null;
return isar.txn(() async {
final model = await isar.cardPackModels.get(id);
if (model == null) return null;
return await packDtoConverter.toEditDto(model);
});
}
Future<bool> editPack(EditCardPackDto dto) async {
return isar.writeTxn(() async {
final oldId = int.tryParse(dto.id ?? '');
final oldModel =
oldId == null ? null : await isar.cardPackModels.get(oldId);
final editModel = packDtoConverter.toModel(dto, oldModel);
final dtoPreviewCardsIds =
dto.previewCards?.map(int.tryParse).whereNotNull().toList() ?? [];
final dtoAddCardsIds =
dto.addCardIds?.map(int.tryParse).whereNotNull().toList() ?? [];
final dtoAddTestIds =
dto.addTestIds?.map(int.tryParse).whereNotNull().toList() ?? [];
final dtoPreviewCards = dtoPreviewCardsIds.isEmpty
? <GameCardModel>[]
: (await isar.gameCardModels.getAll(dtoPreviewCardsIds))
.whereNotNull()
.toList();
final dtoAddCards = dtoAddCardsIds.isEmpty
? <GameCardModel>[]
: (await isar.gameCardModels.getAll(dtoAddCardsIds))
.whereNotNull()
.toList();
final dtoAddTests = dtoAddTestIds.isEmpty
? <TestModel>[]
: (await isar.testModels.getAll(dtoAddTestIds))
.whereNotNull()
.toList();
final modelId = await isar.cardPackModels.put(
editModel.copyWith.version(editModel.version.incVersion),
);
final savedEditModel = (await isar.cardPackModels.get(modelId))!;
if (dtoPreviewCards.isNotEmpty) {
await savedEditModel.previewCards.reset();
savedEditModel.previewCards.addAll(dtoPreviewCards);
await savedEditModel.previewCards.save();
}
await savedEditModel.cards.load();
savedEditModel.cards
..removeWhere(
(card) => dto.removeCardIds?.contains(card.id.toString()) ?? false,
)
..addAll(dtoAddCards);
await savedEditModel.cards.save();
await savedEditModel.tests.load();
savedEditModel.tests
..removeWhere(
(test) => dto.removeTestIds?.contains(test.id.toString()) ?? false,
)
..addAll(dtoAddTests);
await savedEditModel.tests.save();
return true;
});
}
Future<CardPackBuyDto?> getBuyPage(String packId, UserModel? user) async {
final id = int.tryParse(packId);
if (id == null) {
return null;
}
if (await user?.packs.filter().idEqualTo(id).findFirst() == null) {
final model = await _fetchPackModel(packId);
return packDtoConverter.toCardPackBuyDto(model, user);
}
Future<VoiceModel?> getVoice(int id) async {
// TODO: Implement in PackDao
return null;
}
Future<CardPackBuyDto?> getPublicBuyPage(String packId) async {
final id = int.tryParse(packId);
if (id == null) {
return null;
}
final model = await isar.cardPackModels.get(id);
if (model == null || !model.enabled) {
return null;
}
return packDtoConverter.toCardPackBuyDto(model, null);
}
Future<CardPackModel> _fetchPackModel(String packId) async {
return (await isar.cardPackModels.get(int.parse(packId)))!;
}
Future<List<int>> fetchPackImagesArchive(
String packId,
int userId,
String appVersion,
) async {
Future<List<VoiceModel>> getVoices(int cardId) async {
// TODO: Implement in PackDao
return [];
// final pack = await _fetchPackModel(packId);
// final archiveFile = File(
// '${assetsDirectory.path}/pack_archives/${appVersion}/${packId}_${pack.version}.zip',
// );
// try {
// if (await archiveFile.exists()) {
// return await archiveFile.readAsBytes();
// }
// } catch (e, s) {
// log('Error when fetching pack images archive', error: e, stackTrace: s);
// return [];
// }
// final cards = pack.cards;
// final rawData = _fetchPackImages(cards.toList());
// final archive = Archive();
// for (final entry in rawData.entries) {
// archive.addFile(ArchiveFile(entry.key, 0, entry.value));
// }
// final password = TokenGenerator.generateArchivePassword(
// appVersion: appVersion,
// id: packId,
// );
// final bytes = ZipEncoder(
// password: password,
// ).encode(
// archive,
// level: Deflate.BEST_COMPRESSION,
// )!;
// Future<void> saveFile() async {
// try {
// await archiveFile.create(recursive: true);
// await archiveFile.writeAsBytes(bytes);
// } catch (e, s) {
// log(
// 'Error when creating archive ${archiveFile.path}',
// error: e,
// stackTrace: s,
// );
// }
// }
// unawaited(saveFile());
// return bytes;
}
Map<String, List<int>> _fetchPackImages(List<GameCardModel> cards) {
return Map.fromEntries(
cards.map((e) {
final empty = MapEntry(e.id.toString(), <int>[]);
Future<CardPackDto> getPackDto(int id, UserModel? userModel) async {
final pack = await getPack(id);
if (pack == null) {
throw StateError('Pack not found');
}
final cards = await getCards(id);
final voices = <VoiceModel>[];
for (final card in cards) {
voices.addAll(await getVoices(card.id));
}
return await pack.toDto(cards, voices, userModel);
}
Future<List<String>> getPackPreviewImages(int packId) async {
final pack = await getPack(packId);
if (pack == null) {
throw StateError('Pack not found');
}
final cards = await getCards(packId);
// Take first 6 cards for preview (TODO: add previewCardsOrder field to CardPack)
final previewCards = cards.take(6).toList();
final images = <String>[];
for (final card in previewCards) {
if (card.image.isNotEmpty) {
try {
final image = e.image.isEmpty
? empty
: MapEntry(
e.id.toString(),
File('${assetsDirectory.path}/cards/${e.image}')
.readAsBytesSync(),
);
return image;
final image = await card.image.base64Image;
images.add(image);
} catch (e, s) {
log('error while reading image', error: e, stackTrace: s);
return empty;
}
}),
);
}
}
// If we don't have enough preview images, fill with empty strings
while (images.length < 6) {
images.add('');
}
return images;
}
Future<Map<String, Uint8List>> getPackImages(int packId) async {
final cards = await getCards(packId);
final result = <String, Uint8List>{};
for (final card in cards) {
if (card.image.isNotEmpty) {
try {
result[card.id.toString()] =
File('${PackManagerUtils.assetsDirectory.path}/cards/${card.image}')
.readAsBytesSync();
} catch (e, s) {
log('error while reading image', error: e, stackTrace: s);
}
}
}
return result;
}
}
// Static utility functions for PackManager
class PackManagerUtils {
static Directory get assetsDirectory {
String mainPath = Platform.resolvedExecutable;
if ((Platform.isMacOS || Platform.isLinux) &&
@ -481,102 +157,4 @@ class PackManager {
return Directory('');
}
}
static File _getResizedFile(String id, String type, _ImageSize size) =>
File('${PackManager.assetsDirectory.path}/$type/${size.name}/$id');
}
enum _ImageSize {
big,
medium,
small,
extraSmall,
}
extension on _ImageSize {
int get width => switch (this) {
_ImageSize.big => 1024,
_ImageSize.medium => 512,
_ImageSize.small => 320,
_ImageSize.extraSmall => 192,
};
}
extension coverStringExt on String {
Future<String> get base64Image => _base64Image();
Future<String> _base64FromBytes(Uint8List bytes) async =>
base64.normalize(base64Encode(bytes));
// base64 or cardId or 'cards/id' or 'images/id'
Future<String> _base64Image([_ImageSize? size]) async {
try {
if (length > 100) {
if (size != null) {
final bytes = base64Decode(this);
final image = decodeImage(bytes)!;
final reizedImage = copyResize(image, width: size.width);
return _base64FromBytes(encodePng(reizedImage));
}
//is base 64
return this;
}
if (!contains('/')) {
return _base64FromBytes(_getById(this, 'cards', size));
} else if (startsWith('cards/')) {
return _base64FromBytes(_getById(split('/').last, 'cards', size));
} else if (startsWith('images/')) {
return _base64FromBytes(_getById(split('/').last, 'images', size));
}
} catch (e, s) {
log(e.toString(), stackTrace: s);
}
return '';
}
Uint8List _getById(String id, String type, _ImageSize? size) {
if (size == null) {
return File('${PackManager.assetsDirectory.path}/$type/$id')
.readAsBytesSync();
}
final resizedFile = PackManager._getResizedFile(id, type, size);
if (resizedFile.existsSync()) {
try {
return resizedFile.readAsBytesSync();
} catch (_) {
print(
'Error, deleting ${size.name} image with id:$id,type:$type (${resizedFile.path})');
resizedFile.deleteSync();
}
} else {
print(
'No ${size.name} image with id:$id,type:$type (${resizedFile.path})');
}
final file = File('${PackManager.assetsDirectory.path}/$type/$id');
if (file.existsSync()) {
try {
final bytes = file.readAsBytesSync();
final image = decodeImage(bytes)!;
final reizedImage = copyResize(image, width: size.width);
final resizedBytes = encodePng(reizedImage);
resizedFile.createSync(recursive: true);
resizedFile.writeAsBytesSync(resizedBytes);
print(
'Saved ${size.name} image with id:$id,type:$type (${resizedFile.path})');
return resizedBytes;
} catch (e, s) {
log('error while resizing image', error: e, stackTrace: s);
}
}
return Uint8List(0);
}
Future<String> get smallBase64Image => _base64Image(_ImageSize.small);
Future<String> get extraSmallBase64Image =>
_base64Image(_ImageSize.extraSmall);
Future<String> get mediumBase64Image => _base64Image(_ImageSize.medium);
Future<String> get bigSmallBase64Image => _base64Image(null);
}
}

View 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);
}

View 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');
}

View file

@ -1,498 +1,219 @@
import 'dart:developer';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
import 'package:mnemo_cards_backend/extensions.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import '../main.dart';
import 'package:drift/drift.dart' as drift;
@lazySingleton
class PromoCodesManager {
final AppDatabase _db;
final PaymentManager _paymentManager;
PromoCodesManager(this._paymentManager);
PromoCodesManager(this._db, this._paymentManager);
PromoCodeCampaignStatus _parseCampaignStatus(String status) {
switch (status) {
case 'created': return PromoCodeCampaignStatus.created;
case 'preparing': return PromoCodeCampaignStatus.preparing;
case 'ready': return PromoCodeCampaignStatus.ready;
case 'active': return PromoCodeCampaignStatus.active;
case 'disabled': return PromoCodeCampaignStatus.disabled;
default: return PromoCodeCampaignStatus.created;
}
}
Future<List<PromoCodesCampaignDto>> promoCodeCampaigns({
List<String> withCodes = const [],
}) async {
final models = await isar
.txn(() async => isar.promoCodesCampaignModels.where().findAll());
final dtos = await Future.wait(models.map(
(m) async => await m.toDto(
withCodes: withCodes.contains(m.id.toString()),
),
));
return dtos;
final campaigns = await _db.promoCodeDao.getActiveCampaigns();
final campaignDtos = <PromoCodesCampaignDto>[];
for (final campaign in campaigns) {
List<String>? promoCodes;
if (withCodes.isNotEmpty) {
final codes = await _db.promoCodeDao.getPromoCodesByCampaignId(campaign.id);
promoCodes = codes.map((code) => code.code).toList();
}
campaignDtos.add(PromoCodesCampaignDto(
id: campaign.id,
name: campaign.name,
template: campaign.template,
products: [], // TODO: convert from JSON
activationsPerCode: campaign.activationsPerCode,
activationsPerUser: campaign.activationsPerUser,
generationSize: campaign.generationSize,
start: campaign.start,
finish: campaign.finish,
status: _parseCampaignStatus(campaign.status),
tags: campaign.tags,
promoCodes: promoCodes,
));
}
return campaignDtos;
}
Future<PromoCodesCampaignDto?> promoCodeCampaign(String id) async {
final intId = int.parse(id);
final model =
await isar.txn(() async => isar.promoCodesCampaignModels.get(intId));
final dto = await model?.toDto(withCodes: true);
return dto;
}
final campaignId = int.tryParse(id);
if (campaignId == null) return null;
/// Lists available promocodes for a user.
/// Returns active campaigns with promocodes that the user can apply.
Future<List<PromoCodesCampaignDto>> listAvailablePromocodes(
UserModel user,
) async {
await user.userData.load();
final userData = user.userData.value;
if (userData == null) {
return [];
}
final campaign = await _db.promoCodeDao.getCampaignById(campaignId);
if (campaign == null) return null;
await userData.activatedPromoCodes.load();
final activatedPromoCodes = userData.activatedPromoCodes;
final codes = await _db.promoCodeDao.getPromoCodesByCampaignId(campaignId);
final promoCodes = codes.map((code) => code.code).toList();
final now = DateTime.now();
// Get all active campaigns within date range
// Campaign is active if: now >= start && now <= finish
final activeCampaigns = await isar.txn(() async {
return await isar.promoCodesCampaignModels
.filter()
.statusEqualTo(PromoCodeCampaignModelStatus.active)
.startLessThan(now, include: true)
.finishGreaterThan(now, include: true)
.findAll();
});
final availableCampaigns = <PromoCodesCampaignDto>[];
for (final campaign in activeCampaigns) {
// Check if campaign has tags and user matches them
if (campaign.tags.isNotEmpty &&
!campaign.tags.hasIntersection(userData.tags)) {
continue;
}
// Check if user has already reached activationsPerUser limit
final userActivationsForCampaign = activatedPromoCodes
.where((p) => p.campaign.value?.id == campaign.id)
.length;
if (userActivationsForCampaign >= campaign.activationsPerUser) {
continue;
}
// Load promocodes for this campaign
await campaign.promoCodes.load();
final campaignPromoCodes = campaign.promoCodes.toList();
// Filter available promocodes:
// - Not already activated by user
// - Not reached activationsPerCode limit
// - Not individual promocodes for other users
final availableCodes = <String>[];
for (final promoCode in campaignPromoCodes) {
// Skip if already activated by this user
if (activatedPromoCodes.contains(promoCode)) {
continue;
}
// Skip if reached activation limit
if (promoCode.activations >= campaign.activationsPerCode) {
continue;
}
// Check if it's an individual promocode for another user
await promoCode.userData.load();
final codeUserData = promoCode.userData.value;
if (codeUserData != null) {
await codeUserData.user.load();
if (codeUserData.user.value?.id != user.id) {
continue;
}
}
availableCodes.add(promoCode.code);
}
// Only include campaign if it has available codes
if (availableCodes.isNotEmpty) {
final dto = await campaign.toDto(withCodes: false);
availableCampaigns.add(
dto.copyWith(promoCodes: availableCodes),
);
}
}
return availableCampaigns;
}
Future<void> createProductForAdsCampaignIfNeeded(
MnemoCardsProductModel product,
) async {
final prefix = 'ad${product.id}';
// todo - distinguish active campaigns
final shouldCreateCampaign = await isar.txn(
() => isar.promoCodesCampaignModels
.filter()
// todo mb use between
.oneOf(
[
PromoCodeCampaignModelStatus.created,
PromoCodeCampaignModelStatus.preparing,
PromoCodeCampaignModelStatus.active,
PromoCodeCampaignModelStatus.ready,
],
(q, status) => q.statusEqualTo(status),
)
.and()
.templateStartsWith(prefix)
.isEmpty(),
);
if (!shouldCreateCampaign) {
return;
}
final now = DateTime.now();
launchPromoCodesCampaign(
PromoCodesCampaignDto(
products: [product.toDto()],
activationsPerCode: 1,
activationsPerUser: 1,
generationSize: 200,
start: now,
finish: now.add(Duration(days: 365)),
status: PromoCodeCampaignStatus.created,
template: prefix + r'$l$l$l$l$l$l$l$l',
tags: [],
),
return PromoCodesCampaignDto(
id: campaign.id,
name: campaign.name,
template: campaign.template,
products: [], // TODO: convert from JSON
activationsPerCode: campaign.activationsPerCode,
activationsPerUser: campaign.activationsPerUser,
generationSize: campaign.generationSize,
start: campaign.start,
finish: campaign.finish,
status: _parseCampaignStatus(campaign.status),
tags: campaign.tags,
promoCodes: promoCodes,
);
}
Future<bool> launchPromoCodesCampaign(PromoCodesCampaignDto dto) async {
PromoCodesCampaignModel model;
final id = dto.id;
if (id != null) {
final existingModel =
await isar.txn(() => isar.promoCodesCampaignModels.get(id));
if (existingModel == null) {
log('Campaign with id: ${dto.id} not found');
return false;
Future<void> createPromoCodeCampaign(PromoCodesCampaignDto dto) async {
final companion = PromoCodesCampaignsCompanion.insert(
template: dto.template,
name: drift.Value(dto.name),
products: [], // TODO: convert from dto.products
activationsPerCode: dto.activationsPerCode,
activationsPerUser: dto.activationsPerUser,
generationSize: dto.generationSize,
start: dto.start,
finish: dto.finish,
status: dto.status.name,
tags: dto.tags,
);
await _db.promoCodeDao.createCampaign(companion);
}
Future<void> updatePromoCodeCampaign(String id, PromoCodesCampaignDto dto) async {
final campaignId = int.tryParse(id);
if (campaignId == null) {
throw ArgumentError('Invalid campaign ID: $id');
}
final existing = await _db.promoCodeDao.getCampaignById(campaignId);
if (existing == null) {
throw StateError('Campaign not found: $id');
}
final updated = existing.copyWith(
template: dto.template,
name: dto.name,
products: [], // TODO: convert from dto.products
activationsPerCode: dto.activationsPerCode,
activationsPerUser: dto.activationsPerUser,
generationSize: dto.generationSize,
start: dto.start,
finish: dto.finish,
status: dto.status.name,
tags: dto.tags,
updatedAt: DateTime.now(),
);
await _db.promoCodeDao.updateCampaign(updated);
}
Future<String?> deletePromoCodeCampaign(String id) async {
final campaignId = int.tryParse(id);
if (campaignId == null) return 'Invalid campaign ID';
final existing = await _db.promoCodeDao.getCampaignById(campaignId);
if (existing == null) return 'Campaign not found';
// Soft delete - mark as deleted
await _db.promoCodeDao.updateCampaign(existing.copyWith(
isDeleted: true,
updatedAt: DateTime.now(),
));
return null; // No error
}
Future<List<dynamic>> listAvailablePromocodes(dynamic user) async {
// Get user ID
final userId = user is int ? user : int.tryParse(user?.toString() ?? '');
if (userId == null) return [];
final userCodes = await _db.promoCodeDao.getUserPromoCodes(userId);
return userCodes.map((code) => {
'code': code.code,
'activations': code.activations,
'campaignId': code.campaignId,
}).toList();
}
Future<Map<String, dynamic>> validatePromocode(String code, dynamic user) async {
final promoCode = await _db.promoCodeDao.getPromoCodeByCode(code.toUpperCase());
if (promoCode == null) {
return {'valid': false, 'message': 'Promo code not found'};
}
final campaign = await _db.promoCodeDao.getCampaignById(promoCode.campaignId);
if (campaign == null) {
return {'valid': false, 'message': 'Invalid promo code'};
}
final now = DateTime.now();
if (campaign.status != 'active' ||
campaign.start.isAfter(now) ||
campaign.finish.isBefore(now)) {
return {'valid': false, 'message': 'Promo code expired'};
}
if (promoCode.activations >= campaign.activationsPerCode) {
return {'valid': false, 'message': 'Promo code exhausted'};
}
// TODO: Check user-specific limits
return {
'valid': true,
'campaign': {
'id': campaign.id,
'products': campaign.products,
'activationsPerCode': campaign.activationsPerCode,
'activationsPerUser': campaign.activationsPerUser,
}
model = existingModel.copyWith(
activationsPerCode: dto.activationsPerCode,
generationSize: dto.generationSize,
start: dto.start,
finish: dto.finish,
status: dto.status.toModel(),
template: existingModel.template,
name: dto.name,
products: dto.products
.map(
(dto) => MnemoCardsProductModelBase.fromDto(dto),
)
.toList(),
);
} else {
model = PromoCodesCampaignModel(
products: dto.products
.map((product) => MnemoCardsProductModelBase.fromDto(product))
.toList(),
activationsPerCode: dto.activationsPerCode,
activationsPerUser: dto.activationsPerUser,
generationSize: dto.generationSize,
start: dto.start,
finish: dto.finish,
status: PromoCodeCampaignModelStatus.created,
template: dto.template.toUpperCase(),
name: dto.name,
tags: [],
);
}
await isar.writeTxn(() async {
await isar.promoCodesCampaignModels.put(model);
});
// await GeneratePromocodes().task();
return true;
};
}
/// Validates a promocode without applying it.
/// Returns a map with 'valid' (bool) and 'message' (String) keys.
Future<Map<String, dynamic>> validatePromocode(
String code,
UserModel user,
) async {
try {
final result = await isar.txn(() async {
final upperCode = code.toUpperCase();
// Check if code exists
final codeModel = await isar.promoCodeModels
.filter()
.codeEqualTo(upperCode)
.findFirst();
await codeModel?.campaign.load();
final campaign = codeModel?.campaign.value;
if (codeModel == null || campaign == null) {
return {
'valid': false,
'message': 'Промокод не найден',
};
}
// Check campaign status and date range
final now = DateTime.now();
if (campaign.status != PromoCodeCampaignModelStatus.active) {
return {
'valid': false,
'message': 'Промокод недействителен',
};
}
if (now.isBefore(campaign.start)) {
return {
'valid': false,
'message': 'Промокод еще не активен',
};
}
if (now.isAfter(campaign.finish)) {
return {
'valid': false,
'message': 'Промокод истек',
};
}
// Check if code has reached activation limit
if (codeModel.activations >= campaign.activationsPerCode) {
return {
'valid': false,
'message': 'Промокод исчерпан',
};
}
// Load user data
await user.userData.load();
final userData = user.userData.value;
if (userData == null) {
return {
'valid': false,
'message': 'Ошибка при проверке промокода',
};
}
// Check if user has already activated this code
await userData.activatedPromoCodes.load();
final activatedPromoCodes = userData.activatedPromoCodes;
if (activatedPromoCodes.contains(codeModel)) {
return {
'valid': false,
'message': 'Промокод уже был активирован',
};
}
// Check if user has reached activation limit for this campaign
// Load campaign relationships for activated promocodes
for (final activatedCode in activatedPromoCodes) {
await activatedCode.campaign.load();
}
if (activatedPromoCodes
.where((p) => p.campaign.value?.id == campaign.id)
.length >=
campaign.activationsPerUser) {
return {
'valid': false,
'message': 'Вы уже участвовали в этой акции',
};
}
// Check user tags match campaign tags
if (campaign.tags.isNotEmpty &&
!campaign.tags.hasIntersection(userData.tags)) {
return {
'valid': false,
'message': 'Промокод недействителен',
};
}
// Check if it's an individual promocode for another user
await codeModel.userData.load();
final codeUserData = codeModel.userData.value;
if (codeUserData != null) {
await codeUserData.user.load();
if (codeUserData.user.value?.id != user.id) {
return {
'valid': false,
'message': 'Это промокод для другого пользователя',
};
}
}
// All checks passed
return {
'valid': true,
'message': 'Промокод действителен',
};
});
return result;
} catch (e, s) {
log('Error when validating promo code', error: e, stackTrace: s);
return {
'valid': false,
'message': 'Ошибка при проверке промокода',
};
Future<dynamic> applyPromoCode(dynamic dto, dynamic user) async {
final code = dto['code']?.toString()?.toUpperCase();
if (code == null) {
throw ArgumentError('Promo code is required');
}
final validation = await validatePromocode(code, user);
if (!validation['valid']) {
throw StateError(validation['message']);
}
final promoCode = await _db.promoCodeDao.getPromoCodeByCode(code);
if (promoCode == null) return null;
// Increment activations
await _db.promoCodeDao.incrementActivations(promoCode.id);
// TODO: Apply the promo code benefits to user
return {'success': true, 'code': code};
}
Future<PromoCodeDto> applyPromoCode(PromoCodeDto dto, UserModel user) async {
try {
PromoCodeModel? checkedCodeModel;
final checkResult = await isar.writeTxn(() async {
final code = dto.code?.toUpperCase();
if (code == null) {
return _notFoundDto;
}
final codeModel =
await isar.promoCodeModels.filter().codeEqualTo(code).findFirst();
await codeModel?.campaign.load();
final campaign = codeModel?.campaign.value;
if (codeModel == null || campaign == null) {
return _notFoundDto;
}
final now = DateTime.now();
if (codeModel.activations >= campaign.activationsPerCode ||
campaign.status != PromoCodeCampaignStatus.active ||
now.isBefore(campaign.start) ||
now.isAfter(campaign.finish)) {
return _unavailableDto;
}
await user.userData.load();
final userData = user.userData.value;
if (userData == null) {
return _errorDto;
}
await userData.activatedPromoCodes.load();
final activatedPromoCodes = userData.activatedPromoCodes;
if (activatedPromoCodes.contains(codeModel)) {
return PromoCodeDto(
message: 'Промокод уже был активирован',
success: false,
);
}
if (activatedPromoCodes
.where((p) => p.campaign.value?.id == campaign.id)
.length >=
campaign.activationsPerUser) {
return PromoCodeDto(
message: 'Вы уже участвовали в этой акции',
success: false,
);
}
if (campaign.tags.isNotEmpty &&
!campaign.tags.hasIntersection(userData.tags)) {
return _unavailableDto;
}
await codeModel.userData.load();
final codeUserData = codeModel.userData.value;
if (codeUserData != null) {
await codeUserData.user.load();
if (codeUserData.user.value?.id != user.id) {
return PromoCodeDto(
message: 'Это промокод для другого пользователя',
success: false,
);
}
}
checkedCodeModel = codeModel;
return null;
});
if (checkResult != null) {
return checkResult;
}
await _processPromoCode(
checkedCodeModel!.campaign.value!,
checkedCodeModel!,
user,
user.userData.value!,
);
return PromoCodeDto(
message: 'Промокод применен',
success: true,
);
} catch (e, s) {
print('Error when applying promo code error: $e, stackTrace: $s');
return _errorDto;
}
Future<bool> launchPromoCodesCampaign(dynamic campaign) async {
// TODO: Generate promo codes for campaign
// This involves generating codes based on template and generationSize
return false;
}
Future<void> _processPromoCode(
PromoCodesCampaignModel campaign,
PromoCodeModel promoCode,
UserModel user,
UserDataModel userData,
) async {
await isar.writeTxn(() async {
userData.activatedPromoCodes.add(promoCode);
final updatedPromoCode =
promoCode.copyWith(activations: promoCode.activations + 1);
await isar.promoCodeModels.put(updatedPromoCode);
await userData.activatedPromoCodes.save();
await isar.userDataModels.put(userData);
});
final types = campaign.products.map((p) => p.type).toSet();
if (const {
MnemoCardsProductModelType.subscription,
MnemoCardsProductModelType.pack
}.hasIntersection(types)) {
await _paymentManager.processPayment(
PaymentModel(
amount: '0',
currency: 'PROMO',
status: PaymentStatus.succeeded,
paymentSystem: PaymentSystem.promoCode,
userId: user.id!,
date: DateTime.now(),
products: campaign.products,
),
);
}
if (types.contains(MnemoCardsProductModelType.discount)) {}
}
Future<String?> deletePromoCodeCampaign(String stringId) async {
try {
final id = int.parse(stringId);
isar.writeTxn(() async {
final model = await isar.promoCodesCampaignModels.get(id);
if (model == null) {
return 'Campaign $id not found';
}
if (model.status != PromoCodeCampaignModelStatus.disabled) {
return 'Disable campaign before deleting';
}
isar.promoCodesCampaignModels.delete(id);
});
return null;
} catch (e, s) {
log('Error when deleting promo code campaign', error: e, stackTrace: s);
return 'Error $e';
}
}
static const _notFoundDto = PromoCodeDto(
message: 'Промокод не найден',
success: false,
);
static const _errorDto = PromoCodeDto(
message: 'Ошибка при применении промокода',
success: false,
);
static const _unavailableDto = PromoCodeDto(
message: 'Промокод недействителен',
success: false,
);
}
}

View file

@ -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,
);
}

View file

@ -1,422 +1,180 @@
import 'dart:async';
import 'dart:developer';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
/// Service for managing user achievements
///
/// Handles achievement checking, unlocking, and progress tracking.
/// Automatically evaluates user progress against achievement requirements.
@lazySingleton
class AchievementManager {
final Isar _isar;
final AppDatabase _db;
AchievementManager(this._isar);
AchievementManager(this._db);
/// Get all available achievements with their definitions
List<AchievementDefinition> get allAchievementDefinitions =>
_achievementDefinitions;
List<AchievementDto> get allAchievementDefinitions => AchievementDefinitions.allAchievements;
/// Check and update achievements for a user
///
/// Evaluates all achievements against current user data and unlocks any
/// newly achieved accomplishments. Returns list of newly unlocked achievements.
Future<List<AchievementDto>> checkAndUnlockAchievements(
int userId,
UserDataModel userData,
) async {
final newlyUnlocked = <AchievementDto>[];
final unlockedAchievements = <AchievementDto>[];
try {
// Get current user achievements
final currentAchievements = await _getUserAchievements(userId);
// Get existing user achievements
final userAchievements = await getUserAchievements(userId);
final existingIds = userAchievements.map((a) => a.id).toSet();
for (final definition in _achievementDefinitions) {
// Skip if already unlocked
if (currentAchievements
.any((a) => a.id == definition.id && a.isUnlocked)) {
continue;
// Check each achievement
for (final achievement in AchievementDefinitions.allAchievements) {
if (existingIds.contains(achievement.id)) continue;
final isUnlocked = await _checkAchievementCondition(userId, userData, achievement);
if (isUnlocked) {
final unlocked = await unlockAchievement(userId, achievement.id);
if (unlocked != null) {
unlockedAchievements.add(unlocked);
}
// Check if achievement should be unlocked
final shouldUnlock = await _evaluateAchievement(definition, userData);
if (shouldUnlock) {
final unlockedAchievement = definition.unlock();
newlyUnlocked.add(unlockedAchievement);
// Save to database
await _saveAchievement(userId, unlockedAchievement);
log('Achievement unlocked: ${unlockedAchievement.title} for user $userId');
} else {
// Update progress if applicable
final progress = await _calculateAchievementProgress(userId, userData, achievement);
if (progress > 0.0) {
await _db.achievementDao.updateAchievementProgress(userId, achievement.id, progress);
}
}
} catch (e, s) {
log('Error checking achievements for user $userId: $e\n$s');
}
return newlyUnlocked;
}
/// Get achievement progress for a user
///
/// Returns progress (0.0 to 1.0) for achievements that support progress tracking
Future<Map<String, double>> getAchievementProgress(
int userId,
UserDataModel userData,
) async {
final progress = <String, double>{};
for (final definition in _achievementDefinitions) {
if (definition.supportsProgress) {
final currentProgress = await _calculateProgress(definition, userData);
progress[definition.id] = currentProgress;
}
}
return progress;
return unlockedAchievements;
}
/// Get all achievements for a user
/// Get achievements for a user
Future<List<AchievementDto>> getUserAchievements(int userId) async {
return await _getUserAchievements(userId);
final userAchievements = await _db.achievementDao.getUserAchievements(userId);
final progressMap = await _db.achievementDao.getAchievementProgress(userId);
final achievements = <AchievementDto>[];
for (final userAchievement in userAchievements) {
final definition = AchievementDefinitions.getById(userAchievement.achievementId);
if (definition != null) {
achievements.add(definition.copyWith(
unlockedAt: userAchievement.unlockedAt,
progress: userAchievement.progress,
));
}
}
// Add locked achievements with progress
for (final definition in AchievementDefinitions.allAchievements) {
if (!achievements.any((a) => a.id == definition.id)) {
final progress = progressMap[definition.id] ?? 0.0;
achievements.add(definition.updateProgress(progress));
}
}
return achievements;
}
/// Force unlock an achievement for a user (admin function)
Future<bool> forceUnlockAchievement(
/// Check if user has specific achievement
Future<bool> hasAchievement(int userId, String achievementId) async {
return await _db.achievementDao.hasAchievement(userId, achievementId);
}
/// Unlock achievement for user
Future<AchievementDto?> unlockAchievement(
int userId,
String achievementId,
) async {
final definition = _achievementDefinitions.firstWhere(
(def) => def.id == achievementId,
);
final definition = AchievementDefinitions.getById(achievementId);
if (definition == null) return null;
final unlockedAchievement = definition.unlock();
await _saveAchievement(userId, unlockedAchievement);
return true;
}
/// Evaluate if an achievement should be unlocked
Future<bool> _evaluateAchievement(
AchievementDefinition definition,
UserDataModel userData,
) async {
return await definition.evaluate(userData);
}
/// Calculate progress for an achievement
Future<double> _calculateProgress(
AchievementDefinition definition,
UserDataModel userData,
) async {
return await definition.calculateProgress?.call(userData) ?? 0.0;
}
/// Get user achievements from database
Future<List<AchievementDto>> _getUserAchievements(int userId) async {
final user = await _isar.userModels.get(userId);
if (user?.userData.value == null) return [];
return user!.userData.value!.achievements
.map((model) => model.toDto())
.toList();
}
/// Save achievement to user data
Future<void> _saveAchievement(int userId, AchievementDto achievement) async {
final user = await _isar.userModels.get(userId);
if (user?.userData.value == null) return;
final userData = user!.userData.value!;
final model = AchievementModel.fromDto(achievement);
// Update achievements list
final existingAchievements = userData.achievements;
final updatedAchievements = [
...existingAchievements.where((a) => a.id != achievement.id),
model,
];
final updatedUserData =
userData.copyWith(achievements: updatedAchievements);
await _isar.writeTxn(() async {
await _isar.userDataModels.put(updatedUserData);
});
}
/// Achievement definitions
static final List<AchievementDefinition> _achievementDefinitions = [
// First Steps
AchievementDefinition(
id: 'first_word',
title: 'First Word',
description: 'Learn your first word',
type: AchievementType.firstWordLearned,
evaluate: (data) => Future.value(data.words.isNotEmpty),
supportsProgress: false,
),
AchievementDefinition(
id: 'first_test',
title: 'First Test',
description: 'Complete your first test',
type: AchievementType.firstTestCompleted,
evaluate: (data) => Future.value(data.testsStatistics.isNotEmpty),
supportsProgress: false,
),
AchievementDefinition(
id: 'first_pack',
title: 'First Pack',
description: 'Complete your first pack',
type: AchievementType.firstPackCompleted,
evaluate: (data) =>
Future.value(data.packProgress.any((p) => p.progress >= 1.0)),
supportsProgress: false,
),
// Streak Achievements
AchievementDefinition(
id: 'streak_3',
title: '3-Day Streak',
description: 'Study for 3 consecutive days',
type: AchievementType.streak3Days,
evaluate: (data) => Future.value(data.currentStreak >= 3),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.currentStreak / 3.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'streak_7',
title: 'Week Warrior',
description: 'Study for 7 consecutive days',
type: AchievementType.streak7Days,
evaluate: (data) => Future.value(data.currentStreak >= 7),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.currentStreak / 7.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'streak_30',
title: 'Monthly Master',
description: 'Study for 30 consecutive days',
type: AchievementType.streak30Days,
evaluate: (data) => Future.value(data.currentStreak >= 30),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.currentStreak / 30.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'streak_100',
title: 'Century Champion',
description: 'Study for 100 consecutive days',
type: AchievementType.streak100Days,
evaluate: (data) => Future.value(data.currentStreak >= 100),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.currentStreak / 100.0).clamp(0.0, 1.0)),
),
// Words Mastery
AchievementDefinition(
id: 'words_10',
title: 'Word Explorer',
description: 'Learn 10 words',
type: AchievementType.words10Learned,
evaluate: (data) => Future.value(data.words.length >= 10),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.words.length / 10.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'words_50',
title: 'Vocabulary Builder',
description: 'Learn 50 words',
type: AchievementType.words50Learned,
evaluate: (data) => Future.value(data.words.length >= 50),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.words.length / 50.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'words_100',
title: 'Language Learner',
description: 'Learn 100 words',
type: AchievementType.words100Learned,
evaluate: (data) => Future.value(data.words.length >= 100),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.words.length / 100.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'words_500',
title: 'Word Master',
description: 'Learn 500 words',
type: AchievementType.words500Learned,
evaluate: (data) => Future.value(data.words.length >= 500),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.words.length / 500.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'words_1000',
title: 'Vocabulary Expert',
description: 'Learn 1000 words',
type: AchievementType.words1000Learned,
evaluate: (data) => Future.value(data.words.length >= 1000),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.words.length / 1000.0).clamp(0.0, 1.0)),
),
// Performance Achievements
AchievementDefinition(
id: 'perfect_test',
title: 'Perfect Score',
description: 'Complete a test with 100% accuracy',
type: AchievementType.perfectTestScore,
evaluate: (data) => Future.value(
data.testsStatistics.any((test) => test.attempts.any((attempt) {
final totalWords = attempt.words.length;
final correctWords = attempt.words
.where((word) => word.correct > word.incorrect)
.length;
return totalWords > 0 && correctWords == totalWords;
})),
),
supportsProgress: false,
),
AchievementDefinition(
id: 'speed_learner',
title: 'Speed Learner',
description: 'Complete a pack in less than 24 hours',
type: AchievementType.speedLearner,
evaluate: (data) => Future.value(
data.packProgress.any((pack) =>
pack.progress >= 1.0 &&
pack.studyTimeMinutes < 24 * 60 // Less than 24 hours
),
),
supportsProgress: false,
),
// Dedication Achievements
AchievementDefinition(
id: 'dedicated_learner',
title: 'Dedicated Learner',
description: 'Study for 100 hours total',
type: AchievementType.dedicatedLearner,
evaluate: (data) => Future.value(data.totalStudyTimeMinutes >= 100 * 60),
supportsProgress: true,
calculateProgress: (data) => Future.value(
(data.totalStudyTimeMinutes / (100 * 60)).clamp(0.0, 1.0)),
),
// Time-based Achievements
AchievementDefinition(
id: 'early_bird',
title: 'Early Bird',
description: 'Study before 6 AM',
type: AchievementType.earlyBird,
evaluate: (data) => Future.value(
data.studyDates.any((date) => date.hour < 6),
),
supportsProgress: false,
),
AchievementDefinition(
id: 'night_owl',
title: 'Night Owl',
description: 'Study after 10 PM',
type: AchievementType.nightOwl,
evaluate: (data) => Future.value(
data.studyDates.any((date) => date.hour >= 22),
),
supportsProgress: false,
),
// Special Achievements
AchievementDefinition(
id: 'consistent_learner',
title: 'Consistent Learner',
description: 'Study every day for a month',
type: AchievementType.consistentLearner,
evaluate: (data) => Future.value(data.longestStreak >= 30),
supportsProgress: true,
calculateProgress: (data) =>
Future.value((data.longestStreak / 30.0).clamp(0.0, 1.0)),
),
AchievementDefinition(
id: 'language_master',
title: 'Language Master',
description: 'Complete 5 packs',
type: AchievementType.languageMaster,
evaluate: (data) => Future.value(
data.packProgress.where((p) => p.progress >= 1.0).length >= 5,
),
supportsProgress: true,
calculateProgress: (data) => Future.value(
(data.packProgress.where((p) => p.progress >= 1.0).length / 5.0)
.clamp(0.0, 1.0),
),
),
];
}
/// Definition of an achievement with evaluation logic
class AchievementDefinition {
final String id;
final String title;
final String description;
final AchievementType type;
final Future<bool> Function(UserDataModel data) evaluate;
final bool supportsProgress;
final Future<double> Function(UserDataModel data)? calculateProgress;
AchievementDefinition({
required this.id,
required this.title,
required this.description,
required this.type,
required this.evaluate,
this.supportsProgress = false,
this.calculateProgress,
});
/// Create an unlocked achievement DTO
AchievementDto unlock() {
return AchievementDto(
id: id,
title: title,
description: description,
type: type,
final companion = UserAchievementsCompanion.insert(
userId: userId,
achievementId: achievementId,
unlockedAt: DateTime.now(),
progress: 1.0,
);
try {
await _db.achievementDao.unlockAchievement(companion);
return definition.unlock();
} catch (e) {
// Achievement might already exist, return existing
final existing = await _db.achievementDao.getUserAchievement(userId, achievementId);
return existing != null ? definition.unlock() : null;
}
}
/// Create a locked achievement DTO with progress
AchievementDto withProgress(double progress) {
return AchievementDto(
id: id,
title: title,
description: description,
type: type,
progress: progress.clamp(0.0, 1.0),
);
/// Get achievement progress for user
Future<Map<String, double>> getAchievementProgress(int userId) async {
return await _db.achievementDao.getAchievementProgress(userId);
}
}
/// Check if achievement condition is met
Future<bool> _checkAchievementCondition(
int userId,
UserDataModel userData,
AchievementDto achievement,
) async {
switch (achievement.type) {
case AchievementType.firstWordLearned:
return userData.totalCards > 0;
case AchievementType.firstTestCompleted:
return userData.totalTests > 0;
case AchievementType.firstPackCompleted:
final userPacks = await _db.userDao.getUserPacks(userId);
return userPacks.isNotEmpty;
case AchievementType.words10Learned:
return userData.totalCards >= 10;
case AchievementType.words50Learned:
return userData.totalCards >= 50;
case AchievementType.words100Learned:
return userData.totalCards >= 100;
case AchievementType.words500Learned:
return userData.totalCards >= 500;
case AchievementType.words1000Learned:
return userData.totalCards >= 1000;
// TODO: Implement other achievement conditions
// - Streak achievements (need session tracking)
// - Perfect test scores (need test results)
// - Time-based achievements (need session times)
// - Speed learner (need pack completion times)
default:
return false;
}
}
/// Calculate progress towards achievement
Future<double> _calculateAchievementProgress(
int userId,
UserDataModel userData,
AchievementDto achievement,
) async {
switch (achievement.type) {
case AchievementType.words10Learned:
return (userData.totalCards / 10.0).clamp(0.0, 1.0);
case AchievementType.words50Learned:
return (userData.totalCards / 50.0).clamp(0.0, 1.0);
case AchievementType.words100Learned:
return (userData.totalCards / 100.0).clamp(0.0, 1.0);
case AchievementType.words500Learned:
return (userData.totalCards / 500.0).clamp(0.0, 1.0);
case AchievementType.words1000Learned:
return (userData.totalCards / 1000.0).clamp(0.0, 1.0);
default:
return 0.0;
}
}
}

View file

@ -1,17 +1,14 @@
import 'dart:async';
import 'dart:collection';
import 'dart:math';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:drift/drift.dart' as drift;
/// Service for tracking and managing user study sessions
///
/// Automatically creates, updates, and manages study sessions based on user activity.
/// Sessions are created when users start studying and are ended based on inactivity timeouts.
@lazySingleton
class SessionTracker {
final Isar _isar;
final AppDatabase _db;
/// Active sessions cache: userId -> sessionId
final Map<int, String> _activeSessions = {};
@ -22,7 +19,7 @@ class SessionTracker {
/// Session timeout duration (30 minutes by default)
static const Duration _sessionTimeout = Duration(minutes: 30);
SessionTracker(this._isar);
SessionTracker(this._db);
/// Get or create an active session for a user
///
@ -48,237 +45,148 @@ class SessionTracker {
// Create new session
final sessionId = _generateSessionId();
final session = StudySessionModel(
sessionId: sessionId,
userId: userId,
startTime: DateTime.now(),
packId: packId,
testId: testId,
final now = DateTime.now();
await _db.statisticsDao.createSession(
StudySessionsCompanion.insert(
userId: userId,
sessionId: drift.Value(sessionId),
startTime: now,
packId: drift.Value(packId),
testId: drift.Value(testId),
),
);
// Save to database
await _isar.writeTxn(() async {
await _isar.studySessionModels.put(session);
});
// Track as active
// Cache active session
_activeSessions[userId] = sessionId;
_resetSessionTimer(sessionId);
// Start timeout timer
_startSessionTimer(sessionId);
return sessionId;
}
/// Update session with progress data
///
/// Called when user completes a test or makes progress.
Future<void> updateSessionProgress(
String sessionId, {
int wordsLearned = 0,
int testsCompleted = 0,
double? accuracy,
}) async {
final session = await _isar.studySessionModels
.filter()
.sessionIdEqualTo(sessionId)
.findFirst();
if (session == null || !session.isActive) return;
// Update session with new progress
final updatedSession = session.addProgress(
wordsLearned: wordsLearned,
testsCompleted: testsCompleted,
accuracy: accuracy,
);
await _isar.writeTxn(() async {
await _isar.studySessionModels.put(updatedSession);
});
// Reset timeout timer
_resetSessionTimer(sessionId);
}
/// End a session manually with final statistics
///
/// Called when user explicitly ends a session or when session times out.
/// End a session manually
Future<void> endSession(
String sessionId, {
int wordsLearned = 0,
int testsCompleted = 0,
double accuracy = 0.0,
int? wordsLearned,
int? testsCompleted,
double? accuracy,
}) async {
final session = await _isar.studySessionModels
.filter()
.sessionIdEqualTo(sessionId)
.findFirst();
// Cancel timer
_sessionTimers[sessionId]?.cancel();
_sessionTimers.remove(sessionId);
if (session == null || !session.isActive) return;
// Find user for this session
final userId = _activeSessions.entries
.where((entry) => entry.value == sessionId)
.map((entry) => entry.key)
.firstOrNull;
// End session with final statistics
final endedSession = session.end(
if (userId != null) {
_activeSessions.remove(userId);
}
// Update session in database
await _db.statisticsDao.endSession(
sessionId: sessionId,
wordsLearned: wordsLearned,
testsCompleted: testsCompleted,
accuracy: accuracy,
);
await _isar.writeTxn(() async {
await _isar.studySessionModels.put(endedSession);
});
// Clean up tracking
final userId = session.userId;
_activeSessions.remove(userId);
_cancelSessionTimer(sessionId);
}
/// End session for a specific user
Future<void> endUserSession(int userId) async {
final sessionId = _activeSessions[userId];
if (sessionId != null) {
/// Update session statistics
Future<void> updateSessionStats(
String sessionId, {
int? wordsLearned,
int? testsCompleted,
double? accuracy,
}) async {
// Find session by sessionId
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
if (session == null) return;
// Update session
final updatedSession = session.copyWith(
wordsLearned: wordsLearned ?? session.wordsLearned,
testsCompleted: testsCompleted ?? session.testsCompleted,
accuracy: accuracy ?? session.accuracy,
updatedAt: DateTime.now(),
);
await _db.statisticsDao.updateSession(updatedSession);
}
/// Get active session for user
Future<StudySession?> getActiveSession(int userId) async {
return await _db.statisticsDao.getActiveSession(userId);
}
/// Get session history for user
Future<List<StudySession>> getUserSessions(
int userId, {
int? limit,
DateTime? fromDate,
DateTime? toDate,
}) async {
return await _db.statisticsDao.getSessionsByUserId(
userId,
limit: limit,
fromDate: fromDate,
toDate: toDate,
);
}
/// Clean up expired sessions (called by cron job)
Future<void> cleanupExpiredSessions() async {
// End all active sessions that have timed out
final now = DateTime.now();
final expiredSessions = <String>[];
for (final entry in _activeSessions.entries) {
final sessionId = entry.value;
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
if (session != null &&
session.endTime == null &&
now.difference(session.startTime).inMinutes > _sessionTimeout.inMinutes) {
expiredSessions.add(sessionId);
}
}
for (final sessionId in expiredSessions) {
await endSession(sessionId);
}
}
/// Get active session for a user
Future<StudySessionModel?> getActiveSession(int userId) async {
final sessionId = _activeSessions[userId];
if (sessionId == null) return null;
return await _isar.studySessionModels
.filter()
.sessionIdEqualTo(sessionId)
.findFirst();
}
/// Get recent sessions for a user
Future<List<StudySessionModel>> getRecentSessions(
int userId, {
int limit = 10,
}) async {
return await _isar.studySessionModels
.filter()
.userIdEqualTo(userId)
.sortByStartTimeDesc()
.limit(limit)
.findAll();
}
/// Clean up expired sessions
///
/// Should be called periodically (e.g., via cron job)
Future<void> cleanupExpiredSessions() async {
final cutoffTime = DateTime.now().subtract(_sessionTimeout);
// Find sessions that should have expired but are still marked as active
final expiredSessions = await _isar.studySessionModels
.filter()
.endTimeIsNull()
.startTimeLessThan(cutoffTime)
.findAll();
for (final session in expiredSessions) {
await endSession(session.sessionId!);
}
}
/// Get session statistics for a user over a time period
Future<Map<String, dynamic>> getSessionStatistics(
int userId, {
DateTime? from,
DateTime? to,
}) async {
final query = _isar.studySessionModels.filter().userIdEqualTo(userId);
if (from != null) {
query.startTimeGreaterThan(from);
}
if (to != null) {
query.startTimeLessThan(to);
}
final sessions = await query.findAll();
if (sessions.isEmpty) {
return {
'totalSessions': 0,
'totalDurationMinutes': 0,
'totalWordsLearned': 0,
'totalTestsCompleted': 0,
'averageAccuracy': 0.0,
'averageSessionLength': 0.0,
};
}
final completedSessions = sessions.where((s) => !s.isActive).toList();
final totalDuration = completedSessions.fold<int>(
0,
(sum, session) => sum + session.durationMinutes,
);
final totalWordsLearned = completedSessions.fold<int>(
0,
(sum, session) => sum + session.wordsLearned,
);
final totalTestsCompleted = completedSessions.fold<int>(
0,
(sum, session) => sum + session.testsCompleted,
);
final averageAccuracy = completedSessions.isEmpty
? 0.0
: completedSessions.fold<double>(
0,
(sum, session) => sum + session.accuracy,
) /
completedSessions.length;
return {
'totalSessions': sessions.length,
'completedSessions': completedSessions.length,
'totalDurationMinutes': totalDuration,
'totalWordsLearned': totalWordsLearned,
'totalTestsCompleted': totalTestsCompleted,
'averageAccuracy': averageAccuracy,
'averageSessionLength': completedSessions.isEmpty
? 0.0
: totalDuration / completedSessions.length,
};
}
/// Generate a unique session ID
String _generateSessionId() {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final random = DateTime.now().microsecondsSinceEpoch % 10000;
return 'session_${timestamp}_$random';
final random = Random();
final bytes = List<int>.generate(16, (i) => random.nextInt(256));
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}
/// Reset the timeout timer for a session
void _resetSessionTimer(String sessionId) {
_cancelSessionTimer(sessionId);
_sessionTimers[sessionId] = Timer(_sessionTimeout, () {
// Session timed out - end it
endSession(sessionId);
/// Start timeout timer for session
void _startSessionTimer(String sessionId) {
_sessionTimers[sessionId] = Timer(_sessionTimeout, () async {
await endSession(sessionId);
});
}
/// Cancel the timeout timer for a session
void _cancelSessionTimer(String sessionId) {
final timer = _sessionTimers.remove(sessionId);
timer?.cancel();
/// Reset timeout timer for session
void _resetSessionTimer(String sessionId) {
_sessionTimers[sessionId]?.cancel();
_startSessionTimer(sessionId);
}
/// Update session last activity time
/// Update session activity timestamp
Future<void> _updateSessionActivity(String sessionId) async {
// For now, we just reset the timer
// In the future, we could track last activity timestamps
}
/// Dispose of all timers (for cleanup)
void dispose() {
for (final timer in _sessionTimers.values) {
timer.cancel();
final session = await _db.statisticsDao.getSessionBySessionId(sessionId);
if (session != null) {
await _db.statisticsDao.updateSession(
session.copyWith(updatedAt: DateTime.now()),
);
}
_sessionTimers.clear();
_activeSessions.clear();
}
}
}

View 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);
}
}

View file

@ -2,14 +2,14 @@ import 'dart:convert';
import 'dart:io';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
import 'package:mnemo_cards_backend/tests/generators/models/creation_test_data.dart';
import 'package:mnemo_cards_backend/tests/test_extension.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:drift/drift.dart' as drift;
import '../main.dart';
import '../packs/pack_dto_converter.dart';
import 'generators/question_generators/input_buttons_question_generator.dart';
import 'generators/pack_test_generator.dart';
@ -17,214 +17,228 @@ import 'generators/question_generators/simple_question_generator.dart';
@lazySingleton
class TestManager {
final AppDatabase _db;
final PackDtoConverter _packDtoConverter;
List<CreationTestData> _customCreationTestData = [];
TestManager(this._packDtoConverter);
TestManager(this._db, this._packDtoConverter);
Future<TestStatisticsDto?> _testStatisticsDto(
UserModel user, int testId) async {
return (await user.userData.value?.testsStatistics
.filter()
.test((q) => q.idEqualTo(testId))
.findFirst())
?.toDto();
int userId, int testId) async {
final statistics = await _db.testDao.getTestStatistics(userId, testId);
if (statistics == null) return null;
// Convert TestStatistic to TestStatisticsDto
return TestStatisticsDto(
testId: statistics.testId.toString(),
results: statistics.results,
completedAt: statistics.completedAt.toIso8601String(),
);
}
Future<TestDto?> fetchTest(String id, UserModel user) async {
return (await isar.testModels.get(int.parse(id)))?.toDto(
statistics: await _testStatisticsDto(user, int.parse(id)),
final testId = int.tryParse(id);
if (testId == null) return null;
final test = await _db.testDao.getTestById(testId);
if (test == null) return null;
final questions = await _db.testDao.getTestQuestions(testId);
final statistics = await _testStatisticsDto(user.id!, testId);
// Convert Test to TestDto
final questionsList = questions.map((q) {
// Parse question body based on type
final body = json.decode(q.body) as Map<String, dynamic>;
return AbstractTestQuestion.fromJson({
'questionType': q.questionType,
...body,
});
}).toList();
return TestDto(
id: testId.toString(),
name: test.name,
color: test.color,
cover: test.cover,
version: test.version ?? '1.0',
time: test.time,
timeSubtitle: test.timeSubtitle,
questions: questionsList,
statistics: statistics,
);
}
Future<List<TestDto>> availableTests(UserModel userModel) async {
final tests = (await isar.testModels.where().findAll());
final dtos = await Future.wait(tests.map(
(test) async => test.toDto(
questions: [],
statistics: await _testStatisticsDto(userModel, test.id!),
),
));
return dtos.toList();
final tests = await _db.testDao.getAllTests();
final testDtos = <TestDto>[];
for (final test in tests) {
final questions = await _db.testDao.getTestQuestions(test.id);
final statistics = await _testStatisticsDto(userModel.id!, test.id);
final questionsList = questions.map((q) {
final body = json.decode(q.body) as Map<String, dynamic>;
return AbstractTestQuestion.fromJson({
'questionType': q.questionType,
...body,
});
}).toList();
testDtos.add(TestDto(
id: test.id.toString(),
name: test.name,
color: test.color,
cover: test.cover,
version: test.version ?? '1.0',
time: test.time,
timeSubtitle: test.timeSubtitle,
questions: questionsList,
statistics: statistics,
));
}
return testDtos;
}
Future<List<TestDto>> fetchPackTests(
UserModel user,
CardPackModel model,
) async {
final pack = (await isar.cardPackModels.get(model.id!))!;
final Iterable<TestModel> testModels;
if (await _updateGeneratedTestsIfRequired(pack)) {
testModels = (await isar.cardPackModels.get(model.id!))!.tests;
} else {
testModels = pack.tests;
final packId = model.id;
if (packId == null) return [];
final tests = await _db.testDao.getTestsByPackId(packId);
final testDtos = <TestDto>[];
for (final test in tests) {
final questions = await _db.testDao.getTestQuestions(test.id);
final statistics = await _testStatisticsDto(user.id!, test.id);
final questionsList = questions.map((q) {
final body = json.decode(q.body) as Map<String, dynamic>;
return AbstractTestQuestion.fromJson({
'questionType': q.questionType,
...body,
});
}).toList();
testDtos.add(TestDto(
id: test.id.toString(),
name: test.name,
color: test.color,
cover: test.cover,
version: test.version ?? '1.0',
time: test.time,
timeSubtitle: test.timeSubtitle,
questions: questionsList,
statistics: statistics,
));
}
return (await Future.wait(
testModels.map(
(e) async => e.toDto(
statistics: await _testStatisticsDto(user, e.id!),
),
),
))
.toList();
return testDtos;
}
Future<bool> _updateGeneratedTestsIfRequired(CardPackModel model) async {
final generated = model.tests.where((t) => t.version == 'generated');
if (generated.isEmpty) {
final packId = model.id;
if (packId == null) return false;
// Check if pack has any generated tests
final existingTests = await _db.testDao.getTestsByPackId(packId);
final hasGeneratedTests = existingTests.any((test) =>
test.version?.contains('generated') ?? false);
// Generate tests if none exist
if (!hasGeneratedTests) {
await updateGeneratedTests(model);
return true;
}
return false;
}
Future<void> refreshCustomCreationTestsData() async {
final dir = Directory('${PackManager.assetsDirectory.path}/tests/');
List<CreationTestData> data = [];
if (await dir.exists()) {
final files = dir.listSync().whereType<File>().toList();
await Future.wait(
files.map(
(file) => file
.readAsString()
.then((s) => data.add(
s.decode(CreationTestData.fromJson),
))
.catchError(
(err) {
print('Error when loading custom test data: ${file.path}');
print(err);
},
),
),
);
}
_customCreationTestData = data;
// Load custom creation test data from database
// For now, keep it simple
_customCreationTestData = [];
}
Future<void> updateGeneratedTests(CardPackModel model) async {
final oldGenerated =
model.tests.where((t) => t.version == 'generated').toList();
final cardPackDto = _packDtoConverter.toDto(model);
final packCreationTestData = CreationTestData.fromCardPackDto(cardPackDto);
final customCreationTestData = _customCreationTestData.where(
(data) => data.packId == cardPackDto.id,
);
final generator = PackTestGenerator(packCreationTestData);
final testDtos = [
await generator.generate(
name: 'Мини тест',
multiply: 1.0,
ratios: {
TestQuestionType.simple: 1.0,
TestQuestionType.input_buttons: 0.1,
},
),
for (final customData in customCreationTestData)
await PackTestGenerator(
customData,
color: customData.color ?? cardPackDto.color,
generators: {
TestQuestionType.simple: SimpleQuestionGenerator(
customData,
possibleTypes: {
SimpleQuestionType.original_translation,
SimpleQuestionType.translation_original,
SimpleQuestionType.audio_translation,
},
),
},
).generate(
name: customData.title,
multiply: 1.0,
ratios: {
TestQuestionType.simple: 1.0,
},
),
await PackTestGenerator(
packCreationTestData,
generators: {
TestQuestionType.simple: SimpleQuestionGenerator.images(
packCreationTestData,
),
TestQuestionType.input_buttons: InputButtonsQuestionGenerator.images(
packCreationTestData,
),
},
).generate(
name: 'Тест с картинками',
multiply: 2.0,
ratios: {
TestQuestionType.simple: 1.0,
TestQuestionType.input_buttons: 0.25,
},
),
await generator.generate(
name: 'Тест на написание',
multiply: 2.0,
ratios: {
TestQuestionType.input_buttons: 1.0,
},
),
await generator.generate(
name: 'Большой тест',
multiply: 1.5,
ratios: {
TestQuestionType.simple: 1.0,
TestQuestionType.input_buttons: 1.0,
},
),
];
final packId = model.id;
if (packId == null) return;
isar.writeTxn(() async {
for (final test in testDtos) {
final testModel = test.toEmptyModel();
final questionModels = test.questions
.map(
(e) => TestQuestionModel(
questionType: e.questionType,
body: jsonEncode(e.toJson()),
),
)
.toList();
await isar.testQuestionModels.putAll(questionModels);
testModel.questions.addAll(questionModels);
testModel.packs.add(model);
await isar.testModels.put(testModel);
await testModel.packs.save();
await testModel.questions.save();
}
if (oldGenerated.isNotEmpty) {
for (final test in oldGenerated) {
await test.questions.load();
final questions =
test.questions.map((e) => e.id).whereNotNull().toList();
if (questions.isNotEmpty) {
await isar.testQuestionModels.deleteAll(questions);
}
await isar.testModels.delete(test.id!);
}
}
});
// Get pack data from database
final pack = await _db.packDao.getPackById(packId);
if (pack == null) return;
// Get all cards for the pack
final cards = await _db.packDao.getPackCards(packId);
if (cards.isEmpty) return;
// Create creation test data
final testDataItems = cards.map((card) {
return TestDataItem(
id: card.id.toString(),
original: card.original,
translation: card.translation,
mnemo: card.mnemo,
image: card.image,
back: card.back,
transcription: card.transcription,
);
}).toList();
final creationTestData = CreationTestData(
items: testDataItems,
color: pack.color,
packId: packId.toString(),
);
// Generate test using PackTestGenerator
final generator = PackTestGenerator(creationTestData);
final testDto = await generator.generate(
name: '${pack.title} Test',
ratios: {
TestQuestionType.simple: 0.7,
TestQuestionType.input_buttons: 0.3,
},
multiply: 1.0,
);
// Save test to database
await addTest(testDto);
// Link test to pack
final testId = int.tryParse(testDto.id ?? '');
if (testId != null) {
await _db.testDao.linkTestToPack(testId, packId);
}
}
Future<void> addTest(TestDto testDto) async {
await isar.writeTxn(() async {
final questions = <TestQuestionModel>[];
for (final q in testDto.questions.where((q) => q.body != null)) {
final model = TestQuestionModel(
id: q.id,
questionType: q.questionType,
body: q.body!,
await _db.transaction(() async {
// Create test
final testCompanion = TestsCompanion.insert(
name: testDto.name,
color: drift.Value(testDto.color),
cover: drift.Value(testDto.cover),
version: drift.Value(testDto.version),
time: drift.Value(testDto.time),
timeSubtitle: drift.Value(testDto.timeSubtitle),
);
final testId = await _db.testDao.createTest(testCompanion);
// Create questions
for (final question in testDto.questions) {
final questionCompanion = TestQuestionsCompanion.insert(
testId: testId,
questionType: question.questionType.name,
body: json.encode(question.toJson()),
);
await isar.testQuestionModels.put(model);
questions.add(model);
await _db.testDao.createTestQuestion(questionCompanion);
}
final testModel = testDto.toEmptyModel();
await isar.testModels.put(testModel);
await testModel.questions
..addAll(questions)
..save();
});
}
}
}

View 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();
});
}
}

View 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),
);
}
}

View file

@ -1,7 +1,8 @@
import 'dart:developer';
import 'package:injectable/injectable.dart';
import 'package:isar/isar.dart';
import 'package:drift/drift.dart' as drift;
import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
@ -11,77 +12,76 @@ import '../statistics/session_tracker.dart';
import '../statistics/statistics_calculator.dart';
import '../statistics/achievement_manager.dart';
import 'secure.dart';
import 'user_drift_extension.dart';
Map<Id?, DateTime> _onlineUsers = {};
Map<int?, DateTime> _onlineUsers = {};
@lazySingleton
class UserManager {
final AppDatabase _db;
final FreePacksDistributor _freePacksDistributor;
final SessionTracker _sessionTracker;
final StatisticsCalculator _statisticsCalculator;
final AchievementManager _achievementManager;
// TODO: Re-enable when migrated to PostgreSQL
// final SessionTracker _sessionTracker;
// final StatisticsCalculator _statisticsCalculator;
// final AchievementManager _achievementManager;
UserManager(
this._db,
this._freePacksDistributor,
this._sessionTracker,
this._statisticsCalculator,
this._achievementManager,
// this._sessionTracker,
// this._statisticsCalculator,
// this._achievementManager,
);
Future<UserModel?> fetchUser(Id id) async {
final user = await isar.userModels.get(id);
return user;
Future<UserModel?> fetchUser(int id) async {
final user = await _db.userDao.getUserById(id);
if (user == null) return null;
return await user.toUserModel();
}
DateTime? lastOnline(Id id) => _onlineUsers[id];
DateTime? lastOnline(int id) => _onlineUsers[id];
Future<String> createOrGetAuthToken(UserModel user, String externalId) async {
if (user.id == null) {
throw Exception('Cant create token for empty id');
}
final now = DateTime.now();
final tokenModel =
await isar.tokenModels.filter().userIdEqualTo(user.id!).findFirst();
if (tokenModel != null) {
if (tokenModel.expires.isAfter(now)) {
return tokenModel.token;
final token = await _db.userDao.getTokenByUserId(user.id!);
if (token != null) {
if (token.expires.isAfter(now)) {
return token.token;
}
await isar.tokenModels.delete(tokenModel.id!);
await _db.userDao.deleteToken(token.id);
}
final userToken = Secure.token();
await isar.tokenModels.put(
TokenModel(
await _db.userDao.createToken(
TokensCompanion.insert(
token: userToken,
externalUserId: externalId,
userId: user.id!,
created: now,
expires: now.add(Duration(days: 360)),
expires: now.add(const Duration(days: 360)),
),
);
return userToken;
}
Future<UserModel?> getUserByToken(String authToken) async {
final tokenModel =
await isar.tokenModels.filter().tokenEqualTo(authToken).findFirst();
if (tokenModel == null) {
final token = await _db.userDao.getTokenByValue(authToken);
if (token == null) {
return null;
}
final now = DateTime.now();
if (tokenModel.expires.isBefore(now)) {
isar.writeTxn(() => isar.tokenModels.delete(tokenModel.id!));
if (token.expires.isBefore(now)) {
await _db.userDao.deleteToken(token.id);
return null;
}
final user = await isar.userModels.get(tokenModel.userId);
if (user == null) {
return null;
} else {
_onlineUsers[user.id] = now;
final user = await fetchUser(token.userId);
if (user != null) {
_onlineUsers[token.userId] = now;
if (_onlineUsers.length > 300) {
updateOnlineUsers();
}
}
return user;
}
@ -89,28 +89,23 @@ class UserManager {
if (_onlineUsers.isEmpty) {
return;
}
return isar.writeTxn(() async {
final ids = _onlineUsers.keys.whereNotNull().toList();
final users = (await isar.userModels.getAll(ids)).whereNotNull();
await Future.wait(users.map((user) => user.userData.load()));
final updated = users
.map((user) => user.userData.value?.copyWith(
lastTimeOnline: _onlineUsers[user.id] ??
user.userData.value?.lastTimeOnline,
))
.whereNotNull()
.toList();
await isar.userDataModels.putAll(updated);
_onlineUsers.clear();
});
}
Future<List<TokenModel>> getTokensByUser(String userId) async {
final id = int.tryParse(userId);
if (id == null) return [];
return isar.txn(
() => isar.tokenModels.filter().userIdEqualTo(id).findAll(),
);
final userIds = _onlineUsers.keys.whereNotNull().toList();
for (final userId in userIds) {
final lastOnline = _onlineUsers[userId];
if (lastOnline != null) {
await _db.userDao.updateUserDataPartial(
UserDatasCompanion(
userId: drift.Value(userId),
lastTimeOnline: drift.Value(lastOnline),
updatedAt: drift.Value(DateTime.now()),
),
);
}
}
_onlineUsers.clear();
}
Future<(UserModel, String)> createOrGetUser({
@ -118,369 +113,49 @@ class UserManager {
required String email,
String? name,
}) async {
final tokenModel = await isar.tokenModels
.filter()
.externalUserIdEqualTo(externalId)
.findFirst();
var user = tokenModel == null ? null : await fetchUser(tokenModel.userId);
if (user == null) {
print('Creating new user $name $email');
final modelAndToken = await isar.writeTxn(() async {
final userModel = UserModel.empty.copyWith(
email: email,
name: name,
);
await isar.userModels.put(userModel);
print('User $name $email saved');
await isar.tokenModels
.filter()
.externalUserIdEqualTo(externalId)
.deleteFirst();
print('User tokens deleted $name $email');
final token = await createOrGetAuthToken(userModel, externalId);
final userData = UserDataModel()..user.value = userModel;
await isar.userDataModels.put(userData);
userModel.userData.value = userData;
await userModel.userData.save();
return (userModel, token);
});
await _freePacksDistributor.giveFreePacksToUser(modelAndToken.$1);
return modelAndToken;
} else {
if (user.email == null) {
user = user.copyWith.email(email);
await isar.writeTxn(() => isar.userModels.put(user!));
}
await isar.writeTxn(() async {
await user!.userData.load();
if (user.userData.value == null) {
final userData = UserDataModel()..user.value = user;
await isar.userDataModels.put(userData);
user.userData.value = userData;
await user.userData.save();
}
});
return (user, tokenModel!.token);
// Check if user exists by externalId
final existingUser = await _db.userDao.getUserByExternalId(externalId);
if (existingUser != null) {
print('User found $name $email');
final userModel = await existingUser.toUserModel();
final token = await createOrGetAuthToken(userModel, externalId);
return (userModel, token);
}
}
// Future<void> addWordStatistics(
// UserModel user, AllWordsStatisticsDto wordStat) async {
// // final currentData =
// // user.userData?.decode(UserDataDto.fromJson) ?? UserDataDto();
// // updateUserData(
// // user,
// // currentData.copyWith.allWordsStatistics(
// // wordStat.merge(currentData.allWordsStatistics),
// // ),
// // );
// }
print('Creating new user $name $email');
Future<void> addTestStatistics(
UserModel user,
TestStatisticsDto testStat,
) async {
UserDataModel currentData;
print('adding test stats');
if (user.userData.value == null) {
print('new user data');
currentData = UserDataModel();
await isar.writeTxn(() async {
final id = await isar.userDataModels.put(
currentData..user.value = user,
);
currentData = (await isar.userDataModels.get(id))!;
});
print('user data saved');
} else {
print('found user data');
currentData = user.userData.value!;
}
if (testStat.sessionToken == currentData.lastTestSessionToken) {
print('old session token');
return;
}
final testStatistics = await isar.txn(
() async =>
await currentData.testsStatistics
.filter()
.test((q) => q.idEqualTo(testStat.testId))
.findFirst() ??
TestStatisticsModel(),
// Create new user
final userCompanion = UsersCompanion.insert(
externalUserId: externalId,
name: drift.Value(name),
email: drift.Value(email),
);
print('got tests stat for ${testStat.testId}');
final userId = await _db.userDao.createUser(userCompanion);
final allWords = currentData.words.mergeWithDto(testStat.words.words);
print(
'merged all words: ${allWords.length} (was ${currentData.words.length})');
return isar.writeTxn(() async {
try {
final test = await isar.testModels.get(testStat.testId);
// final testWords = updateWords(testStatistics.words, testStat.words.words);
final updatedTestStat = testStatistics.copyWith.attempts(
[
...testStatistics.attempts.takeLast(2),
TestAttempt(
words: testStat.words.words.map((e) => e.toModel()).toList(),
sessionToken: testStat.sessionToken,
),
],
)..test.value = test;
await isar.testStatisticsModels.put(updatedTestStat);
await updatedTestStat.test.save();
print('updated test stats saved');
// Calculate updated statistics
final studyDates = List<DateTime>.from(currentData.studyDates);
final today = DateTime.now();
final todayNormalized = DateTime(today.year, today.month, today.day);
// Add today's study date if not already present
if (!studyDates.any((date) =>
date.year == todayNormalized.year &&
date.month == todayNormalized.month &&
date.day == todayNormalized.day)) {
studyDates.add(todayNormalized);
}
// Calculate current streak based on study dates
final currentStreak = _statisticsCalculator.calculateStreak(studyDates);
// Calculate longest streak
final longestStreak = currentStreak > currentData.longestStreak
? currentStreak
: currentData.longestStreak;
// Calculate total study time (simplified - add time from this test)
final testDurationMinutes = 5; // Assume 5 minutes per test as default
final totalStudyTimeMinutes =
currentData.totalStudyTimeMinutes + testDurationMinutes;
// Calculate pack progress if we have pack info
final packProgress =
List<PackProgressDto>.from(currentData.packProgress);
// Update pack progress if test was for a specific pack
if (testStat.testId != null) {
// This would need pack information lookup - simplified for now
// In a real implementation, we'd update the specific pack's progress
}
// Check and unlock achievements with updated data
final tempUpdatedData = currentData.copyWith(
currentStreak: currentStreak,
longestStreak: longestStreak,
studyDates: studyDates,
totalStudyTimeMinutes: totalStudyTimeMinutes,
words: allWords,
);
// Check for newly unlocked achievements
await checkAndUpdateAchievements(user, tempUpdatedData);
final updatedData = currentData.copyWith(
lastTestSessionToken: testStat.sessionToken,
words: allWords,
currentStreak: currentStreak,
longestStreak: longestStreak,
studyDates: studyDates,
totalStudyTimeMinutes: totalStudyTimeMinutes,
lastTimeOnline: today,
)
..testsStatistics.add(updatedTestStat)
..user.value = user;
// Note: packProgress and achievements are handled separately
// since UserDataModel expects Model types, not DTO types
print('saving updated user data');
await isar.userDataModels.put(updatedData);
await updatedData.testsStatistics.save();
await updatedData.user.save();
print('user data saved');
// Track session activity
await _updateSessionFromTest(user, testStat, updatedData);
} catch (e, s) {
print('error while saving data ${e.toString()} ${s.toString()}');
log('error', error: e, stackTrace: s);
}
});
}
/// Check and update achievements for a user
Future<List<AchievementDto>> checkAndUpdateAchievements(
UserModel user,
UserDataModel userData,
) async {
try {
final newlyUnlockedAchievements =
await _achievementManager.checkAndUnlockAchievements(
user.id!,
userData,
);
if (newlyUnlockedAchievements.isNotEmpty) {
print(
'New achievements unlocked for user ${user.id}: ${newlyUnlockedAchievements.map((a) => a.title).join(', ')}');
}
return newlyUnlockedAchievements;
} catch (e, s) {
print('Error checking achievements for user ${user.id}: $e\n$s');
return [];
}
}
/// Update session tracking based on test completion
Future<void> _updateSessionFromTest(
UserModel user,
TestStatisticsDto testStat,
UserDataModel updatedData,
) async {
if (user.id == null) return;
try {
// Get or create session for this user
final sessionId = await _sessionTracker.getOrCreateSession(
user.id!,
testId: testStat.testId.toString(),
);
// Calculate test statistics
final wordsLearned = testStat.words.words.length;
final correctAnswers = testStat.words.words.fold<int>(
0,
(sum, word) => sum + word.correct.toInt(),
);
final totalAnswers = testStat.words.words.fold<int>(
0,
(sum, word) => sum + word.correct.toInt() + word.incorrect.toInt(),
);
final accuracy = totalAnswers > 0 ? correctAnswers / totalAnswers : 0.0;
// Update session progress
await _sessionTracker.updateSessionProgress(
sessionId,
wordsLearned: wordsLearned,
testsCompleted: 1,
accuracy: accuracy,
);
print(
'Updated session $sessionId: +$wordsLearned words, accuracy: ${accuracy.toStringAsFixed(2)}');
} catch (e, s) {
print('Error updating session tracking: $e\n$s');
// Don't fail the main operation if session tracking fails
}
}
Future<void> updateUserSettings(UserModel user, UserSettingsDto settings) {
return isar.writeTxn(
() => isar.userModels.put(
user.copyWith(userSettings: settings.encode()),
// Create user data
await _db.userDao.createUserData(
UserDatasCompanion.insert(
userId: userId,
registrationDate: drift.Value(DateTime.now()),
),
);
final user = await _db.userDao.getUserById(userId);
if (user == null) {
throw Exception('Failed to create user');
}
final userModel = await user.toUserModel();
final token = await createOrGetAuthToken(userModel, externalId);
// Give free packs to new user
await _freePacksDistributor.giveFreePacksToUser(userModel);
print('User $name $email created successfully');
return (userModel, token);
}
Future<bool> editUser(UserDto user) async {
if (user.id == null || user.id! < 0) {
throw Exception('Cant edit new user');
}
final existUser = await isar.userModels.get(user.id!);
if (existUser == null) {
throw Exception('User ${user.id} not found');
}
final dtoPackIds = user.packs.map((s) => int.tryParse(s)).whereNotNull();
final dtoPacks = dtoPackIds.isEmpty
? <CardPackModel>[]
: (await isar.cardPackModels.getAll(dtoPackIds.toList()))
.whereNotNull()
.toList();
isar.writeTxn(() async {
final updatedUser = existUser.copyWith(
name: user.name ?? existUser.name,
// subscription: user.subscription,
// userSettings: user.userSettingsDto?.encode() ?? existUser.userSettings,
// userData: user.userDataDto?.encode() ?? existUser.userData,
);
final id = await isar.userModels.put(updatedUser);
final packs = (await isar.userModels.get(id))!.packs;
await updatedUser.subscriptionModel.load();
final subscriptionModel = updatedUser.subscriptionModel.value;
print(
'Editing user ${user.id}, subscription = ${user.subscription} and hasModel = ${subscriptionModel != null}',
);
if (user.subscription == true) {
UserSubscriptionModel userSubscriptionModel;
if (subscriptionModel == null) {
print('Creating new sub model');
userSubscriptionModel = UserSubscriptionModel(
start: DateTime.now(),
finish: DateTime.now().add(Duration(days: 1)),
features: [
SubscriptionFeatureEnum.packs,
SubscriptionFeatureEnum.ads,
],
);
} else {
print('Updating sub model');
userSubscriptionModel = subscriptionModel.copyWith(
start: DateTime.now(),
finish: DateTime.now().add(Duration(days: 1)),
features: [
SubscriptionFeatureEnum.packs,
SubscriptionFeatureEnum.ads,
]);
}
updatedUser.subscriptionModel.value = userSubscriptionModel;
await isar.userSubscriptionModels.put(userSubscriptionModel);
await updatedUser.subscriptionModel.save();
} else if (subscriptionModel != null) {
final updatedModel = subscriptionModel.copyWith(
start: DateTime.now(),
finish: DateTime.now(),
features: [],
)..user.value = updatedUser;
await isar.userSubscriptionModels.put(updatedModel);
updatedUser.subscriptionModel.value = updatedModel;
await updatedUser.subscriptionModel.save();
}
await packs.reset();
await (packs..addAll(dtoPacks)).save();
});
return true;
}
Future<bool> deleteUser(String stringId) async {
final id = int.tryParse(stringId);
if (id != null) {
await isar.writeTxn(() async {
final tokens = await isar.tokenModels
.filter()
.userIdEqualTo(id)
.idProperty()
.findAll();
if (tokens.isNotEmpty) {
await isar.tokenModels.deleteAll(tokens);
}
await isar.testStatisticsModels
.filter()
.userData((q) => q.user((u) => u.idEqualTo(id)))
.deleteAll();
await isar.userDataModels
.filter()
.user((q) => q.idEqualTo(id))
.deleteAll();
return isar.userModels.delete(id);
});
return true;
}
return false;
}
}
// TODO: Implement remaining methods as needed
// updateUserSettings, addTestStatistics, editUser, deleteUser
}

View 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
}

View file

@ -129,15 +129,31 @@ paths:
responses:
200:
description: "Operation completed!"
/ads/product/acquire/<key>:
/cards:
get:
tags:
- AdminCardsApiV2
summary: getAllCards
operationId: getAllCards
responses:
200:
description: "Operation completed!"
post:
tags:
- AdsApiV2
summary: "POST /api/v2/ads/product/acquire/{key}"
description: Confirms rewarded ad completion and grants product access to the user.
operationId: acquireProductForAd
- AdminCardsApiV2
summary: createCard
operationId: createCard
responses:
200:
description: "Operation completed!"
/cards/<cardId>:
get:
tags:
- AdminCardsApiV2
summary: getCard
operationId: getCard
parameters:
- name: key
- name: cardId
in: path
required: true
schema:
@ -145,42 +161,11 @@ paths:
responses:
200:
description: "Operation completed!"
/adsgram/reward:
get:
tags:
- AdsApiV2
summary: "GET /api/v2/adsgram/reward?userId={userId}"
description: Callback endpoint for Adsgram rewarded ad completion.\nThis endpoint is called by Adsgram when a user completes a rewarded ad.
operationId: adsgramRewardCallback
responses:
200:
description: "Operation completed!"
/admin/cards:
get:
put:
tags:
- AdminCardsApiV2
summary: getCards
description: "GET /api/v2/admin/cards\nGet all cards with pagination and optional search\nQuery params: ?page=1&limit=20&search=term"
operationId: getCards
responses:
200:
description: "Operation completed!"
post:
tags:
- AdminCardsApiV2
summary: upsertCard
description: POST /api/v2/admin/cards\nCreate or update a card
operationId: upsertCard
responses:
200:
description: "Operation completed!"
/admin/cards/<cardId>:
get:
tags:
- AdminCardsApiV2
summary: getCard
description: "GET /api/v2/admin/cards/:id\nGet a specific card by ID"
operationId: getCard
summary: updateCard
operationId: updateCard
parameters:
- name: cardId
in: path
@ -194,7 +179,6 @@ paths:
tags:
- AdminCardsApiV2
summary: deleteCard
description: "DELETE /api/v2/admin/cards/:id\nDelete a card by ID"
operationId: deleteCard
parameters:
- name: cardId
@ -354,275 +338,6 @@ paths:
responses:
200:
description: "Operation completed!"
/admin/users:
get:
tags:
- AdminUsersApiV2
summary: getUsers
description: GET /api/v2/admin/users\nReturns list of users by optional ids.
operationId: getUsers
responses:
200:
description: "Operation completed!"
post:
tags:
- AdminUsersApiV2
summary: upsertUser
description: POST /api/v2/admin/users\nCreates or updates user data (admin editing).
operationId: upsertUser
responses:
200:
description: "Operation completed!"
/admin/users/ids:
get:
tags:
- AdminUsersApiV2
summary: getUserIds
description: GET /api/v2/admin/users/ids\nReturns comma separated user ids.
operationId: getUserIds
responses:
200:
description: "Operation completed!"
/admin/users/<userId>/purchases:
get:
tags:
- AdminUsersApiV2
summary: getUserPurchases
description: "GET /api/v2/admin/users/<id>/purchases\nReturns user payments history."
operationId: getUserPurchases
parameters:
- name: userId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/admin/users/<userId>:
delete:
tags:
- AdminUsersApiV2
summary: deleteUser
description: "DELETE /api/v2/admin/users/<id>\nDeletes user."
operationId: deleteUser
parameters:
- name: userId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/games:
get:
tags:
- GamesApiV2
summary: getGames
description: GET /api/v2/games\nGet all available games\nReturns list of games with metadata
operationId: getGames
responses:
200:
description: "Operation completed!"
/games/<gameId>/assets:
get:
tags:
- GamesApiV2
summary: getGameAssets
description: "GET /api/v2/games/{gameId}/assets\nGet game assets\nReturns game assets file (zip) or asset info"
operationId: getGameAssets
parameters:
- name: gameId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/packs:
get:
tags:
- PacksApiV2
summary: getPacks
description: "GET /api/v2/packs\nGet all pack previews with pagination\nQuery params: ?search=term&language=lang&page=1&limit=20"
operationId: getPacks
responses:
200:
description: "Operation completed!"
/packs/<packId>:
get:
tags:
- PacksApiV2
summary: getPack
description: "GET /api/v2/packs/{packId}\nGet pack details by ID\nReturns full pack details with purchase status if authenticated"
operationId: getPack
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/packs/<packId>/buy:
get:
tags:
- PacksApiV2
summary: getPackBuyPage
description: "GET /api/v2/packs/{packId}/buy\nReturns pack purchase details (includes rewarded ads offer when available)\nWorks for both authenticated and unauthenticated users"
operationId: getPackBuyPage
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/packs/<packId>/cards:
get:
tags:
- PacksApiV2
summary: getPackCards
description: "GET /api/v2/packs/{packId}/cards\nGet all cards in a pack\nSupports pagination via query params: ?page=1&limit=20"
operationId: getPackCards
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/packs/<packId>/cards/<cardId>/image:
get:
tags:
- PacksApiV2
summary: getCardImage
description: "GET /api/v2/packs/{packId}/cards/{cardId}/image\nGet card image\nReturns PNG image file\n\nImages are accessible for enabled packs even without authentication\nto allow image preview in public pack listings"
operationId: getCardImage
parameters:
- name: packId
in: path
required: true
schema:
type: string
- name: cardId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/packs/<packId>/cards/<cardId>/voices:
get:
tags:
- PacksApiV2
summary: getCardVoices
description: "GET /api/v2/packs/{packId}/cards/{cardId}/voices\nGet card voices metadata\nReturns JSON list of VoiceDto"
operationId: getCardVoices
parameters:
- name: packId
in: path
required: true
schema:
type: string
- name: cardId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/voice/<voiceId>:
get:
tags:
- PacksApiV2
summary: getVoiceFile
description: "GET /api/v2/voice/{voiceId}\nReturns audio/mp3 bytes for the voice file"
operationId: getVoiceFile
parameters:
- name: voiceId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/packs/<packId>/tests:
get:
tags:
- PacksApiV2
summary: getPackTests
description: "GET /api/v2/packs/{packId}/tests\nGet tests for a pack"
operationId: getPackTests
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/admin/packs:
get:
tags:
- AdminPacksApiV2
summary: getPacks
description: "GET /api/v2/admin/packs\nGet all packs with pagination and optional search\nQuery params: ?page=1&limit=20&search=term&showDisabled=true"
operationId: getPacks
responses:
200:
description: "Operation completed!"
post:
tags:
- AdminPacksApiV2
summary: upsertPack
description: POST /api/v2/admin/packs\nCreate or update a pack
operationId: upsertPack
responses:
200:
description: "Operation completed!"
/admin/packs/<packId>:
get:
tags:
- AdminPacksApiV2
summary: getPack
description: "GET /api/v2/admin/packs/:id\nGet pack details by ID for editing"
operationId: getPack
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
delete:
tags:
- AdminPacksApiV2
summary: deletePack
description: "DELETE /api/v2/admin/packs/:id\nDelete a pack by ID"
operationId: deletePack
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/admin/auth/request-code:
post:
tags:
@ -669,64 +384,6 @@ paths:
responses:
200:
description: "Operation completed!"
/purchases/packs/<packId>:
post:
tags:
- PurchasesApiV2
summary: createPackPurchase
description: "POST /api/v2/purchases/packs/{packId}\nCreate purchase intent for a pack\nReturns purchase info including payment URL for YooKassa"
operationId: createPackPurchase
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/purchases/packs/<packId>/status:
get:
tags:
- PurchasesApiV2
summary: getPackPurchaseStatus
description: "GET /api/v2/purchases/packs/{packId}/status\nCheck if pack is purchased by the authenticated user"
operationId: getPackPurchaseStatus
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/purchases/payments:
post:
tags:
- PurchasesApiV2
summary: createPayment
description: POST /api/v2/purchases/payments\nCreate a payment\nCurrently supports YooKassa for web payments
operationId: createPayment
responses:
200:
description: "Operation completed!"
/purchases/payments/<paymentId>/verify:
get:
tags:
- PurchasesApiV2
summary: verifyPayment
description: "GET /api/v2/purchases/payments/{paymentId}/verify\nVerify payment status\nUpdates user purchases on success"
operationId: verifyPayment
parameters:
- name: paymentId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/auth/oauth/google:
post:
tags:
@ -880,6 +537,56 @@ paths:
responses:
200:
description: "Operation completed!"
/telegram-bot/random-card:
get:
tags:
- TelegramBotApiV2
summary: getRandomCard
description: "GET /api/v2/telegram-bot/random-card\nGet a random card from all available cards"
operationId: getRandomCard
responses:
200:
description: "Operation completed!"
/telegram-bot/share/check-limit:
post:
tags:
- TelegramBotApiV2
summary: checkShareLimit
description: "POST /api/v2/telegram-bot/share/check-limit\nCheck if user can share today (rate limiting)\nBody: { telegramUserId: string, dailyLimit: number }"
operationId: checkShareLimit
responses:
200:
description: "Operation completed!"
/telegram-bot/share/record:
post:
tags:
- TelegramBotApiV2
summary: recordShareRequest
description: "POST /api/v2/telegram-bot/share/record\nRecord a share request for a user\nBody: { telegramUserId: string, telegramUsername?: string, sharedCardId?: number }"
operationId: recordShareRequest
responses:
200:
description: "Operation completed!"
/telegram-bot/users/info:
get:
tags:
- TelegramBotApiV2
summary: getUsersInfo
description: "GET /api/v2/telegram-bot/users/info\nGet user information (for admin commands)\nQuery: ?userId=<id> for specific user, or no query for all users summary"
operationId: getUsersInfo
responses:
200:
description: "Operation completed!"
/telegram-bot/words:
get:
tags:
- TelegramBotApiV2
summary: getWords
description: "GET /api/v2/telegram-bot/words\nGet all words from all cards\nQuery: ?separator=<string> for custom separator (default: comma)"
operationId: getWords
responses:
200:
description: "Operation completed!"
/admin/analytics/dashboard:
get:
tags:
@ -969,7 +676,7 @@ paths:
tags:
- TasksApiV2
summary: "GET /api/v2/users/me/tasks/progress - Get user task progress"
operationId: getUserProgress
operationId: getUserTaskProgress
responses:
200:
description: "Operation completed!"
@ -977,94 +684,31 @@ paths:
get:
tags:
- TasksApiV2
summary: "GET /api/v2/tasks/categories - Get available task categories and filters"
summary: "GET /api/v2/tasks/categories - Get task categories"
operationId: getTaskCategories
responses:
200:
description: "Operation completed!"
/telegram-bot/random-card:
get:
tags:
- TelegramBotApiV2
summary: getRandomCard
description: "GET /api/v2/telegram-bot/random-card\nGet a random card from all available cards"
operationId: getRandomCard
responses:
200:
description: "Operation completed!"
/telegram-bot/share/check-limit:
post:
tags:
- TelegramBotApiV2
summary: checkShareLimit
description: "POST /api/v2/telegram-bot/share/check-limit\nCheck if user can share today (rate limiting)\nBody: { telegramUserId: string, dailyLimit: number }"
operationId: checkShareLimit
responses:
200:
description: "Operation completed!"
/telegram-bot/share/record:
post:
tags:
- TelegramBotApiV2
summary: recordShareRequest
description: "POST /api/v2/telegram-bot/share/record\nRecord a share request for a user\nBody: { telegramUserId: string, telegramUsername?: string, sharedCardId?: number }"
operationId: recordShareRequest
responses:
200:
description: "Operation completed!"
/telegram-bot/users/info:
get:
tags:
- TelegramBotApiV2
summary: getUsersInfo
description: "GET /api/v2/telegram-bot/users/info\nGet user information (for admin commands)\nQuery: ?userId=<id> for specific user, or no query for all users summary"
operationId: getUsersInfo
responses:
200:
description: "Operation completed!"
/telegram-bot/words:
get:
tags:
- TelegramBotApiV2
summary: getWords
description: "GET /api/v2/telegram-bot/words\nGet all words from all cards\nQuery: ?separator=<string> for custom separator (default: comma)"
operationId: getWords
responses:
200:
description: "Operation completed!"
components: { }
tags:
- name: PromocodesApiV2
description: API v2 endpoints for promocode management and activation.
- name: DiscountsApiV2
description: Admin endpoints for discount campaign management.
- name: AdsApiV2
description: API v2 endpoints for rewarded ads flows.
- name: AdminCardsApiV2
description: Admin endpoints for card management in API v2.
- name: SubscriptionsApiV2
description: "Subscriptions API v2\nRESTful endpoints for managing subscriptions & plans"
- name: UsersApiV2
description: "API v2 endpoints for user profile and self-service operations."
- name: AdminUsersApiV2
description: Admin endpoints for user management in API v2.
- name: GamesApiV2
description: Games API v2\n\nRESTful endpoints for managing games and game assets
- name: PacksApiV2
description: API v2 Packs endpoints\n\nRESTful endpoints for card packs with pagination and filtering
- name: AdminPacksApiV2
description: Admin endpoints for pack management in API v2.
- name: AdminAuthApiV2
description: Admin authentication API endpoints
- name: PurchasesApiV2
description: Purchases API v2\n\nRESTful endpoints for managing purchases and payments
- name: AuthApiV2
description: API v2 Authentication endpoints\n\nImplements OAuth2/JWT Bearer token authentication
- name: TestsApiV2
description: Tests API v2\n\nRESTful endpoints for managing tests and test results
- name: TelegramBotApiV2
description: "API v2 endpoints for Telegram Bot\n\nThese endpoints are authenticated via X-API-Key header\nand provide functionality previously accessed directly via Isar DB"
- name: AdminAnalyticsApiV2
description: Admin endpoints for analytics and statistics in API v2.
- name: TasksApiV2
description: API v2 endpoints for user tasks management
- name: TelegramBotApiV2
description: "API v2 endpoints for Telegram Bot\n\nThese endpoints are authenticated via X-API-Key header\nand provide functionality previously accessed directly via Isar DB"
description: API v2 endpoints for user tasks management

View file

@ -25,6 +25,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.13.0"
analyzer_plugin:
dependency: transitive
description:
name: analyzer_plugin
sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d
url: "https://pub.dev"
source: hosted
version: "0.11.2"
archive:
dependency: "direct dev"
description:
@ -81,6 +89,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
buffer:
dependency: transitive
description:
name: buffer
sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1"
url: "https://pub.dev"
source: hosted
version: "1.2.3"
build:
dependency: transitive
description:
@ -145,14 +161,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.9.2"
characters:
charcode:
dependency: transitive
description:
name: characters
sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605"
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
@ -169,6 +185,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.0"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c
url: "https://pub.dev"
source: hosted
version: "0.4.2"
clock:
dependency: transitive
description:
@ -241,14 +265,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
dartx:
dependency: transitive
description:
name: dartx
sha256: "8b25435617027257d43e6508b5fe061012880ddfdaa75a71d607c3de2a13d244"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
dio:
dependency: "direct main"
description:
@ -265,6 +281,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.1"
drift:
dependency: "direct main"
description:
name: drift
sha256: b50a8342c6ddf05be53bda1d246404cbad101b64dc73e8d6d1ac1090d119b4e2
url: "https://pub.dev"
source: hosted
version: "2.15.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: c037d9431b6f8dc633652b1469e5f53aaec6e4eb405ed29dd232fa888ef10d88
url: "https://pub.dev"
source: hosted
version: "2.15.0"
drift_postgres:
dependency: "direct main"
description:
name: drift_postgres
sha256: "8c1e123c72aeb3982af53681d4c9327d2a54a980f3bc8658cd891429b0dd3b09"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
encrypt:
dependency: "direct main"
description:
@ -426,21 +466,13 @@ packages:
source: hosted
version: "1.0.4"
isar:
dependency: "direct main"
dependency: transitive
description:
name: isar
sha256: "99165dadb2cf2329d3140198363a7e7bff9bbd441871898a87e26914d25cf1ea"
url: "https://pub.dev"
source: hosted
version: "3.1.0+1"
isar_generator:
dependency: "direct dev"
description:
name: isar_generator
sha256: "76c121e1295a30423604f2f819bc255bc79f852f3bc8743a24017df6068ad133"
url: "https://pub.dev"
source: hosted
version: "3.1.0+1"
jaguar_jwt:
dependency: "direct main"
description:
@ -591,6 +623,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.1"
postgres:
dependency: "direct main"
description:
name: postgres
sha256: "83ba7afb0e778cc1f373e514c6110d323b81dfbaced774d3a7f4a0e21536eb45"
url: "https://pub.dev"
source: hosted
version: "3.4.8"
pub_semver:
dependency: transitive
description:
@ -631,6 +671,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
sasl_scram:
dependency: transitive
description:
name: sasl_scram
sha256: a47207a436eb650f8fdcf54a2e2587b850dc3caef9973ce01f332b07a6fc9cb9
url: "https://pub.dev"
source: hosted
version: "0.1.1"
saslprep:
dependency: transitive
description:
name: saslprep
sha256: "3d421d10be9513bf4459c17c5e70e7b8bc718c9fc5ad4ba5eb4f5fd27396f740"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
shelf:
dependency: "direct main"
description:
@ -767,6 +823,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.10.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
sqlparser:
dependency: transitive
description:
name: sqlparser
sha256: "7b20045d1ccfb7bc1df7e8f9fee5ae58673fce6ff62cefbb0e0fd7214e90e5a0"
url: "https://pub.dev"
source: hosted
version: "0.34.1"
stack_trace:
dependency: transitive
description:
@ -831,14 +903,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.5"
time:
dependency: transitive
description:
name: time
sha256: ad8e018a6c9db36cb917a031853a1aae49467a93e0d464683e029537d848c221
url: "https://pub.dev"
source: hosted
version: "2.1.4"
timing:
dependency: transitive
description:
@ -855,14 +919,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.2"
unorm_dart:
dependency: transitive
description:
name: unorm_dart
sha256: "0c69186b03ca6addab0774bcc0f4f17b88d4ce78d9d4d8f0619e30a99ead58e7"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
uuid:
dependency: "direct main"
description:
name: uuid
sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313"
sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8
url: "https://pub.dev"
source: hosted
version: "3.0.7"
version: "4.5.2"
version:
dependency: transitive
description:
@ -927,14 +999,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.5.0"
xxh3:
dependency: transitive
description:
name: xxh3
sha256: a92b30944a9aeb4e3d4f3c3d4ddb3c7816ca73475cd603682c4f8149690f56d7
url: "https://pub.dev"
source: hosted
version: "1.0.1"
yaml:
dependency: transitive
description:
@ -947,9 +1011,9 @@ packages:
dependency: "direct main"
description:
name: yookassa_client
sha256: "667d04d0e2d8c7e5180d26a3966587cc99d38fb887d318e36562a202d1adb6e2"
sha256: e801e1bb22f21f883adbee15645e2c9b21c4a640f8e096006a6295c335c588aa
url: "https://pub.dev"
source: hosted
version: "1.0.2"
version: "1.0.5"
sdks:
dart: ">=3.4.0 <4.0.0"
dart: ">=3.5.0 <4.0.0"

View file

@ -4,8 +4,7 @@ description: Mnemo backend
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+4
isar_version: &isar_version 3.1.0+1 # define the version to be used
version: 1.0.0+5
environment:
sdk: '>=3.0.0 <4.0.0'
@ -19,12 +18,16 @@ dependencies:
mnemo_cards_common_backend:
path: ../mnemo_cards_common_backend
# PostgreSQL + Drift
drift: ^2.14.0
drift_postgres: ^1.1.0
postgres: ^3.0.4
image: ^4.1.7
http: ^1.1.0
# shared_preferences: ^2.2.2
async:
isar: *isar_version
# path_provider: ^2.1.1
get_it: ^7.6.4
injectable: ^2.4.1
@ -46,7 +49,7 @@ dependencies:
crypto: ^3.0.3
googleapis: ^13.1.0
googleapis_auth:
uuid: ^3.0.7
uuid: ^4.5.2
yookassa_client: ^1.0.2
neat_periodic_task: ^2.0.1
@ -54,8 +57,11 @@ dependencies:
dev_dependencies:
build_runner:
isar_generator: *isar_version
build_runner: ^2.4.0
# Drift code generation
drift_dev: ^2.14.0
shelf_router_generator: ^1.1.0
shelf_open_api_generator:
injectable_generator: