product availability
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 2026-01-08 19:58:13 +03:00
parent e3afe9888c
commit 829542714a
19 changed files with 219 additions and 207 deletions

View file

@ -20,6 +20,7 @@ import '../../packs/free_packs_distributor.dart' as _i1062;
import '../../packs/pack_dto_converter.dart' as _i433; import '../../packs/pack_dto_converter.dart' as _i433;
import '../../packs/pack_manager.dart' as _i833; import '../../packs/pack_manager.dart' as _i833;
import '../../packs/pack_repository.dart' as _i258; import '../../packs/pack_repository.dart' as _i258;
import '../../packs/product_availability_manager.dart' as _i797;
import '../../packs/products_price_resolver.dart' as _i908; import '../../packs/products_price_resolver.dart' as _i908;
import '../../promo_codes/promo_codes_manager.dart' as _i151; import '../../promo_codes/promo_codes_manager.dart' as _i151;
import '../../statistics/achievement_manager.dart' as _i802; import '../../statistics/achievement_manager.dart' as _i802;
@ -158,20 +159,6 @@ extension GetItInjectableX on _i174.GetIt {
gh.lazySingleton<_i985.TasksApiV2>( gh.lazySingleton<_i985.TasksApiV2>(
() => _i985.TasksApiV2(gh<_i586.TaskManager>()), () => _i985.TasksApiV2(gh<_i586.TaskManager>()),
); );
gh.lazySingleton<_i1009.PaymentManager>(
() => _i1009.PaymentManager(
gh<_i1072.AppDatabase>(),
gh<_i950.UserRepository>(),
gh<_i988.YooMoneyHandler>(),
gh<_i222.RustorePurchaseHandler>(),
),
);
gh.lazySingleton<_i151.PromoCodesManager>(
() => _i151.PromoCodesManager(
gh<_i1072.AppDatabase>(),
gh<_i1009.PaymentManager>(),
),
);
gh.factory<_i1015.AdminPacksApiV2>( gh.factory<_i1015.AdminPacksApiV2>(
() => _i1015.AdminPacksApiV2( () => _i1015.AdminPacksApiV2(
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
@ -184,57 +171,15 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
), ),
); );
gh.lazySingleton<_i273.PromocodesApiV2>(
() => _i273.PromocodesApiV2(gh<_i151.PromoCodesManager>()),
);
gh.lazySingleton<_i908.ProductsPriceResolver>( gh.lazySingleton<_i908.ProductsPriceResolver>(
() => _i908.ProductsPriceResolver( () => _i908.ProductsPriceResolver(
gh<_i891.DiscountsManager>(), gh<_i891.DiscountsManager>(),
gh<_i950.UserRepository>(), gh<_i950.UserRepository>(),
), ),
); );
gh.lazySingleton<_i1062.FreePacksDistributor>(
() => _i1062.FreePacksDistributor(
gh<_i950.UserRepository>(),
gh<_i258.PackRepository>(),
),
);
gh.lazySingleton<_i735.CheckPaymentTask>(
() => _i735.CheckPaymentTask(
gh<_i1009.PaymentManager>(),
gh<_i1072.AppDatabase>(),
),
);
gh.lazySingleton<_i858.DiscountsApiV2>( gh.lazySingleton<_i858.DiscountsApiV2>(
() => _i858.DiscountsApiV2(gh<_i891.DiscountsManager>()), () => _i858.DiscountsApiV2(gh<_i891.DiscountsManager>()),
); );
gh.lazySingleton<_i280.UserManager>(
() => _i280.UserManager(
gh<_i1072.AppDatabase>(),
gh<_i950.UserRepository>(),
gh<_i1062.FreePacksDistributor>(),
gh<_i71.SessionTracker>(),
gh<_i1029.StatisticsCalculator>(),
gh<_i802.AchievementManager>(),
gh<_i909.WordStatisticsManager>(),
),
);
gh.lazySingleton<_i247.UsersApiV2>(
() => _i247.UsersApiV2(
gh<_i280.UserManager>(),
gh<_i1009.PaymentManager>(),
gh<_i1029.StatisticsCalculator>(),
gh<_i950.UserRepository>(),
gh<_i1072.AppDatabase>(),
),
);
gh.lazySingleton<_i483.AdminAuthApiV2>(
() => _i483.AdminAuthApiV2(
gh<_i240.TelegramAuthCodeService>(),
gh<_i280.UserManager>(),
gh<_i108.JwtService>(),
),
);
gh.lazySingleton<_i433.PackDtoConverter>( gh.lazySingleton<_i433.PackDtoConverter>(
() => _i433.PackDtoConverter( () => _i433.PackDtoConverter(
gh<_i908.ProductsPriceResolver>(), gh<_i908.ProductsPriceResolver>(),
@ -248,6 +193,37 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i433.PackDtoConverter>(), gh<_i433.PackDtoConverter>(),
), ),
); );
gh.lazySingleton<_i797.ProductAvailabilityManager>(
() => _i797.ProductAvailabilityManager(
gh<_i833.PackManager>(),
gh<_i1072.AppDatabase>(),
publicPackIds: gh<Set<String>>(),
),
);
gh.lazySingleton<_i1062.FreePacksDistributor>(
() => _i1062.FreePacksDistributor(
gh<_i797.ProductAvailabilityManager>(),
gh<_i258.PackRepository>(),
),
);
gh.lazySingleton<_i280.UserManager>(
() => _i280.UserManager(
gh<_i1072.AppDatabase>(),
gh<_i950.UserRepository>(),
gh<_i1062.FreePacksDistributor>(),
gh<_i71.SessionTracker>(),
gh<_i1029.StatisticsCalculator>(),
gh<_i802.AchievementManager>(),
gh<_i909.WordStatisticsManager>(),
),
);
gh.lazySingleton<_i483.AdminAuthApiV2>(
() => _i483.AdminAuthApiV2(
gh<_i240.TelegramAuthCodeService>(),
gh<_i280.UserManager>(),
gh<_i108.JwtService>(),
),
);
gh.lazySingleton<_i560.UserManager>( gh.lazySingleton<_i560.UserManager>(
() => _i560.UserManager( () => _i560.UserManager(
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
@ -260,12 +236,13 @@ extension GetItInjectableX on _i174.GetIt {
gh.lazySingleton<_i561.MnemoShelf>( gh.lazySingleton<_i561.MnemoShelf>(
() => _i561.MnemoShelf(gh<_i280.UserManager>()), () => _i561.MnemoShelf(gh<_i280.UserManager>()),
); );
gh.lazySingleton<_i69.PurchasesApiV2>( gh.lazySingleton<_i1009.PaymentManager>(
() => _i69.PurchasesApiV2( () => _i1009.PaymentManager(
gh<_i1009.PaymentManager>(),
gh<_i833.PackManager>(),
gh<_i950.UserRepository>(),
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
gh<_i950.UserRepository>(),
gh<_i797.ProductAvailabilityManager>(),
gh<_i988.YooMoneyHandler>(),
gh<_i222.RustorePurchaseHandler>(),
), ),
); );
gh.lazySingleton<_i800.PacksApiV2>( gh.lazySingleton<_i800.PacksApiV2>(
@ -273,11 +250,17 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i833.PackManager>(), gh<_i833.PackManager>(),
gh<_i259.TestManager>(), gh<_i259.TestManager>(),
gh<_i258.PackRepository>(), gh<_i258.PackRepository>(),
gh<_i950.UserRepository>(), gh<_i797.ProductAvailabilityManager>(),
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
gh<_i747.MinioService>(), gh<_i747.MinioService>(),
), ),
); );
gh.lazySingleton<_i735.CheckPaymentTask>(
() => _i735.CheckPaymentTask(
gh<_i1009.PaymentManager>(),
gh<_i1072.AppDatabase>(),
),
);
gh.lazySingleton<_i170.TestsApiV2>( gh.lazySingleton<_i170.TestsApiV2>(
() => _i170.TestsApiV2( () => _i170.TestsApiV2(
gh<_i259.TestManager>(), gh<_i259.TestManager>(),
@ -285,6 +268,15 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
), ),
); );
gh.lazySingleton<_i69.PurchasesApiV2>(
() => _i69.PurchasesApiV2(
gh<_i1009.PaymentManager>(),
gh<_i833.PackManager>(),
gh<_i950.UserRepository>(),
gh<_i797.ProductAvailabilityManager>(),
gh<_i1072.AppDatabase>(),
),
);
gh.lazySingleton<_i52.AuthApiV2>( gh.lazySingleton<_i52.AuthApiV2>(
() => _i52.AuthApiV2( () => _i52.AuthApiV2(
gh<_i1072.AppDatabase>(), gh<_i1072.AppDatabase>(),
@ -294,6 +286,24 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i240.TelegramAuthCodeService>(), gh<_i240.TelegramAuthCodeService>(),
), ),
); );
gh.lazySingleton<_i247.UsersApiV2>(
() => _i247.UsersApiV2(
gh<_i280.UserManager>(),
gh<_i1009.PaymentManager>(),
gh<_i1029.StatisticsCalculator>(),
gh<_i950.UserRepository>(),
gh<_i1072.AppDatabase>(),
),
);
gh.lazySingleton<_i151.PromoCodesManager>(
() => _i151.PromoCodesManager(
gh<_i1072.AppDatabase>(),
gh<_i1009.PaymentManager>(),
),
);
gh.lazySingleton<_i273.PromocodesApiV2>(
() => _i273.PromocodesApiV2(gh<_i151.PromoCodesManager>()),
);
return this; return this;
} }
} }

View file

@ -10,6 +10,7 @@ import 'package:drift/drift.dart' as drift;
import 'payment_drift_extension.dart'; import 'payment_drift_extension.dart';
import 'rustore/rustore_purchase_handler.dart'; import 'rustore/rustore_purchase_handler.dart';
import 'yoo_money.dart'; import 'yoo_money.dart';
import '../../packs/product_availability_manager.dart';
import '../../user/user_repository.dart'; import '../../user/user_repository.dart';
/// Result of creating YooKassa payment URL /// Result of creating YooKassa payment URL
@ -27,6 +28,7 @@ class YookassaPaymentResult {
class PaymentManager { class PaymentManager {
final AppDatabase _db; final AppDatabase _db;
final UserRepository _userRepository; final UserRepository _userRepository;
final ProductAvailabilityManager _productAvailabilityManager;
late final GooglePlayPurchaseHandler googlePurchaseHandler; late final GooglePlayPurchaseHandler googlePurchaseHandler;
final YooMoneyHandler _yooMoneyHandler; final YooMoneyHandler _yooMoneyHandler;
final RustorePurchaseHandler _rustorePurchaseHandler; final RustorePurchaseHandler _rustorePurchaseHandler;
@ -34,6 +36,7 @@ class PaymentManager {
PaymentManager( PaymentManager(
this._db, this._db,
this._userRepository, this._userRepository,
this._productAvailabilityManager,
this._yooMoneyHandler, this._yooMoneyHandler,
this._rustorePurchaseHandler, this._rustorePurchaseHandler,
); );
@ -79,13 +82,12 @@ class PaymentManager {
) async { ) async {
await _db.transaction(() async { await _db.transaction(() async {
if (product.type == MnemoCardsProductType.pack && product.id != null) { if (product.type == MnemoCardsProductType.pack && product.id != null) {
final packId = product.id!; await _productAvailabilityManager.grantProductAccess(
await _userRepository.grantPackAccess(
userId: userId, userId: userId,
packId: packId, product: product,
grantType: 'promo_code', grantType: 'promo_code',
); );
log('Granted pack $packId to user $userId via promo code'); log('Granted pack ${product.id} to user $userId via promo code');
} else if (product.type == MnemoCardsProductType.subscription && } else if (product.type == MnemoCardsProductType.subscription &&
product.id != null) { product.id != null) {
final planId = product.id!; final planId = product.id!;
@ -99,7 +101,7 @@ class PaymentManager {
userId: userId, userId: userId,
start: PgDateTime(now), start: PgDateTime(now),
finish: PgDateTime(endDate), finish: PgDateTime(endDate),
features: drift.Value(plan.features as List<dynamic>), features: drift.Value(plan.features),
), ),
); );
log('Granted subscription $planId to user $userId via promo code'); log('Granted subscription $planId to user $userId via promo code');
@ -120,7 +122,10 @@ class PaymentManager {
} }
// Получить пользователя // Получить пользователя
final userModel = await _userRepository.getUserById(payment.userId, withData: true); final userModel = await _userRepository.getUserById(
payment.userId,
withData: true,
);
if (userModel == null) { if (userModel == null) {
log('User not found: ${payment.userId}'); log('User not found: ${payment.userId}');
return; return;
@ -160,7 +165,7 @@ class PaymentManager {
await _db.transaction(() async { await _db.transaction(() async {
// Дать доступ к пакетам // Дать доступ к пакетам
for (final packId in packIds) { for (final packId in packIds) {
await _userRepository.grantPackAccess( await _productAvailabilityManager.grantPackAccess(
userId: payment.userId, userId: payment.userId,
packId: packId, packId: packId,
grantType: 'purchase', grantType: 'purchase',

View file

@ -61,25 +61,15 @@ class AdminAnalyticsApiV2 {
final packCount = await _db.packDao.countPacks(); final packCount = await _db.packDao.countPacks();
// Get all packs and filter in memory // Get all packs and filter in memory
final enabledPacks = await _db.packDao.getAllPacks( final enabledPacks = await _db.packDao.getAllPacks(enabledOnly: true);
enabledOnly: true,
);
final enabledPackCount = enabledPacks.length; final enabledPackCount = enabledPacks.length;
final paymentCount = await _db.paymentDao.countAllPayments(); final paymentCount = await _db.paymentDao.countAllPayments();
// Get recent users (last 10) // Get recent users (last 10)
final allUsers = await _userRepository.getAllUsers( final allUsers = await _userRepository.getAllUsers(limit: 10);
limit: 10,
);
final recentUsers = allUsers final recentUsers = allUsers
.map( .map((u) => {'id': u.id, 'name': u.name, 'email': u.email})
(u) => {
'id': u.id,
'name': u.name,
'email': u.email,
},
)
.toList(); .toList();
// Get top packs by user count // Get top packs by user count

View file

@ -160,7 +160,10 @@ class AdminUsersApiV2 {
}, statusCode: 400); }, statusCode: 400);
} }
final userModel = await _userRepository.getUserById(userId, includePacks: true); final userModel = await _userRepository.getUserById(
userId,
includePacks: true,
);
if (userModel == null) { if (userModel == null) {
return _json({ return _json({
'error': 'User not found', 'error': 'User not found',
@ -219,7 +222,9 @@ class AdminUsersApiV2 {
if (isUpdate) { if (isUpdate) {
// Update existing user // Update existing user
final existingUserModel = await _userRepository.getUserById(userDto.id!); final existingUserModel = await _userRepository.getUserById(
userDto.id!,
);
if (existingUserModel == null) { if (existingUserModel == null) {
return _json({ return _json({
'error': 'User not found', 'error': 'User not found',
@ -254,7 +259,10 @@ class AdminUsersApiV2 {
await _userRepository.updateUserPartial(updates); await _userRepository.updateUserPartial(updates);
// Get updated user // Get updated user
final updatedUserModel = await _userRepository.getUserById(userDto.id!, includePacks: true); final updatedUserModel = await _userRepository.getUserById(
userDto.id!,
includePacks: true,
);
if (updatedUserModel == null) { if (updatedUserModel == null) {
return _json({ return _json({
'error': 'Database error', 'error': 'Database error',
@ -275,7 +283,9 @@ class AdminUsersApiV2 {
: 'admin_created_${DateTime.now().millisecondsSinceEpoch}'; : 'admin_created_${DateTime.now().millisecondsSinceEpoch}';
// Check if user with this externalUserId already exists // Check if user with this externalUserId already exists
final existingUserModel = await _userRepository.getUserByExternalId(externalUserId); final existingUserModel = await _userRepository.getUserByExternalId(
externalUserId,
);
if (existingUserModel != null) { if (existingUserModel != null) {
return _json({ return _json({
'error': 'User already exists', 'error': 'User already exists',
@ -307,7 +317,10 @@ class AdminUsersApiV2 {
userData: userDataCompanion, userData: userDataCompanion,
); );
final createdUserModel = await _userRepository.getUserById(userId, includePacks: true); final createdUserModel = await _userRepository.getUserById(
userId,
includePacks: true,
);
if (createdUserModel == null) { if (createdUserModel == null) {
return _json({ return _json({
'error': 'Database error', 'error': 'Database error',

View file

@ -12,12 +12,11 @@ import 'package:mnemo_cards_backend/packs/card_image_storage.dart';
import 'package:mnemo_cards_backend/packs/card_model_extension.dart'; import 'package:mnemo_cards_backend/packs/card_model_extension.dart';
import 'package:mnemo_cards_backend/packs/pack_manager.dart' show PackManager; import 'package:mnemo_cards_backend/packs/pack_manager.dart' show PackManager;
import 'package:mnemo_cards_backend/packs/pack_repository.dart'; import 'package:mnemo_cards_backend/packs/pack_repository.dart';
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
import 'package:mnemo_cards_backend/packs/voice_storage.dart'; import 'package:mnemo_cards_backend/packs/voice_storage.dart';
import 'package:mnemo_cards_backend/storage/minio_config.dart'; import 'package:mnemo_cards_backend/storage/minio_config.dart';
import 'package:mnemo_cards_backend/storage/minio_service.dart'; import 'package:mnemo_cards_backend/storage/minio_service.dart';
import 'package:mnemo_cards_backend/tests/test_manager.dart'; import 'package:mnemo_cards_backend/tests/test_manager.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:shelf_open_api/shelf_open_api.dart'; import 'package:shelf_open_api/shelf_open_api.dart';
@ -33,7 +32,7 @@ class PacksApiV2 {
final PackManager _packManager; final PackManager _packManager;
final TestManager _testManager; final TestManager _testManager;
final PackRepository _packRepository; final PackRepository _packRepository;
final UserRepository _userRepository; final ProductAvailabilityManager _productAvailabilityManager;
final AppDatabase _db; final AppDatabase _db;
final MinioService _minioService; final MinioService _minioService;
@ -41,7 +40,7 @@ class PacksApiV2 {
this._packManager, this._packManager,
this._testManager, this._testManager,
this._packRepository, this._packRepository,
this._userRepository, this._productAvailabilityManager,
this._db, this._db,
this._minioService, this._minioService,
); );
@ -66,12 +65,6 @@ class PacksApiV2 {
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
); );
Response _conflict(String message) => Response(
409,
body: jsonEncode({'error': 'Conflict', 'message': message}),
headers: {'Content-Type': 'application/json'},
);
Response _forbidden([String? message]) => Response( Response _forbidden([String? message]) => Response(
403, 403,
body: jsonEncode({ body: jsonEncode({
@ -289,10 +282,11 @@ class PacksApiV2 {
final packJson = packDto.toJson(); final packJson = packDto.toJson();
if (user != null) { if (user != null) {
final isPurchased = final isAvailable = await _productAvailabilityManager.isPackAvailable(
await _isPackPurchased(user.id!, packId) || packId: packId,
(await _hasSubscriptionAccess(user)); user: user,
packJson['isPurchased'] = isPurchased; );
packJson['isPurchased'] = isAvailable;
} }
return _ok(packJson); return _ok(packJson);
@ -331,10 +325,11 @@ class PacksApiV2 {
// Include purchase status in response if user is authenticated // Include purchase status in response if user is authenticated
if (user != null) { if (user != null) {
final isPurchased = final isAvailable = await _productAvailabilityManager.isPackAvailable(
await _isPackPurchased(user.id!, packId) || packId: packId,
(await _hasSubscriptionAccess(user)); user: user,
packJson['isPurchased'] = isPurchased; );
packJson['isPurchased'] = isAvailable;
} }
return _ok(packJson); return _ok(packJson);
@ -834,11 +829,6 @@ class PacksApiV2 {
} }
} }
/// Check if user purchased a pack
Future<bool> _isPackPurchased(String userId, String packId) async {
return await _userRepository.hasPackAccess(userId, packId);
}
/// GET /api/v2/packs/{packId}/tests /// GET /api/v2/packs/{packId}/tests
/// Get tests for a pack /// Get tests for a pack
@Route.get('/packs/<packId>/tests') @Route.get('/packs/<packId>/tests')
@ -900,16 +890,6 @@ class PacksApiV2 {
} }
} }
/// Helper method to check if user has subscription access
Future<bool> _hasSubscriptionAccess(UserModel user) async {
// Check subscription features from user model
final subscription = user.subscriptionModel;
if (subscription == null) {
return false;
}
return subscription.features.contains(SubscriptionFeatureEnum.packs);
}
/// Convert Drift VoiceModel to VoiceDto /// Convert Drift VoiceModel to VoiceDto
VoiceDto _voiceModelToDto( VoiceDto _voiceModelToDto(
drift.VoiceModel voice, { drift.VoiceModel voice, {

View file

@ -6,7 +6,7 @@ import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart'; import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/packs/pack_manager.dart'; import 'package:mnemo_cards_backend/packs/pack_manager.dart';
import 'package:mnemo_cards_backend/packs/products_price_resolver.dart'; import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/user/user_repository.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
@ -23,12 +23,14 @@ class PurchasesApiV2 {
final PaymentManager _paymentManager; final PaymentManager _paymentManager;
final PackManager _packManager; final PackManager _packManager;
final UserRepository _userRepository; final UserRepository _userRepository;
final ProductAvailabilityManager _productAvailabilityManager;
final AppDatabase _db; final AppDatabase _db;
PurchasesApiV2( PurchasesApiV2(
this._paymentManager, this._paymentManager,
this._packManager, this._packManager,
this._userRepository, this._userRepository,
this._productAvailabilityManager,
this._db, this._db,
); );
@ -101,11 +103,14 @@ class PurchasesApiV2 {
print( print(
'🔍 createPackPurchase: Checking pack access for userId=${user.id}, packId=$packId', '🔍 createPackPurchase: Checking pack access for userId=${user.id}, packId=$packId',
); );
// Check if already purchased // Check if already purchased or available through subscription
final hasAccess = await _userRepository.hasPackAccess(user.id!, packId); final isAvailable = await _productAvailabilityManager.isPackAvailable(
if (hasAccess) { packId: packId,
print('❌ createPackPurchase: Pack already purchased'); user: user,
return _badRequest('Pack is already purchased'); );
if (isAvailable) {
print('❌ createPackPurchase: Pack already available');
return _badRequest('Pack is already available');
} }
print('✅ createPackPurchase: Pack not purchased yet'); print('✅ createPackPurchase: Pack not purchased yet');
@ -207,10 +212,13 @@ class PurchasesApiV2 {
return _notFound('Pack not found'); return _notFound('Pack not found');
} }
// Check if already purchased // Check if already purchased or available through subscription
final hasAccess = await _userRepository.hasPackAccess(user.id!, productId); final isAvailable = await _productAvailabilityManager.isPackAvailable(
if (hasAccess) { packId: productId,
return _badRequest('Pack is already purchased'); user: user,
);
if (isAvailable) {
return _badRequest('Pack is already available');
} }
// Get price (with discounts if applicable) // Get price (with discounts if applicable)
@ -370,24 +378,30 @@ class PurchasesApiV2 {
return _notFound('Pack not found'); return _notFound('Pack not found');
} }
// Check purchase status // Check availability (includes both direct purchase and subscription)
final isPurchased = await _userRepository.hasPackAccess(user.id!, packId); final isAvailable = await _productAvailabilityManager.isPackAvailable(
packId: packId,
user: user,
);
// Check subscription access // Check direct purchase access separately
final hasDirectAccess = await _db.userDao.hasPackAccess(user.id!, packId);
// Check subscription access separately
final activeSubscription = await _db.subscriptionDao final activeSubscription = await _db.subscriptionDao
.getActiveSubscription(user.id!); .getActiveSubscription(user.id!);
final hasSubscriptionAccess = final hasSubscriptionAccess =
activeSubscription != null && activeSubscription != null &&
(activeSubscription.features is List && activeSubscription.features.contains(
(activeSubscription.features as List).contains( SubscriptionFeatureEnum.packs.name,
SubscriptionFeatureEnum.packs.name, );
));
return _ok({ return _ok({
'packId': packId, 'packId': packId,
'isPurchased': isPurchased, 'isPurchased': hasDirectAccess,
'purchased': isPurchased, 'purchased': hasDirectAccess,
'hasSubscriptionAccess': hasSubscriptionAccess, 'hasSubscriptionAccess': hasSubscriptionAccess,
'isAvailable': isAvailable,
}); });
} catch (e, s) { } catch (e, s) {
developer.log( developer.log(

View file

@ -193,7 +193,10 @@ class TelegramBotApiV2 {
if (userId != null && userId.isNotEmpty) { if (userId != null && userId.isNotEmpty) {
// Get specific user info // Get specific user info
final users = <UserModel>[]; final users = <UserModel>[];
final user = await _userRepository.getUserById(userId, includePacks: true); final user = await _userRepository.getUserById(
userId,
includePacks: true,
);
if (user != null) { if (user != null) {
users.add(user); users.add(user);
} }

View file

@ -258,7 +258,10 @@ class UsersApiV2 {
} }
// Convert UserData to UserDataDto // Convert UserData to UserDataDto
final userDataModel = await _userRepository.getUserById(user.id!, withData: true); final userDataModel = await _userRepository.getUserById(
user.id!,
withData: true,
);
if (userDataModel?.userData == null) { if (userDataModel?.userData == null) {
return _json({'error': 'user_data_not_found'}, statusCode: 404); return _json({'error': 'user_data_not_found'}, statusCode: 404);
} }

View file

@ -1,4 +1,5 @@
import 'package:mnemo_cards_backend/packs/free_packs_distributor.dart'; import 'package:mnemo_cards_backend/packs/free_packs_distributor.dart';
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/user/user_repository.dart';
import 'task.dart' as task; import 'task.dart' as task;
@ -6,8 +7,13 @@ import 'task.dart' as task;
class AddFreePacks with task.Task { class AddFreePacks with task.Task {
final FreePacksDistributor _freePacksDistributor; final FreePacksDistributor _freePacksDistributor;
final UserRepository _userRepository; final UserRepository _userRepository;
final ProductAvailabilityManager _productAvailabilityManager;
AddFreePacks(this._freePacksDistributor, this._userRepository); AddFreePacks(
this._freePacksDistributor,
this._userRepository,
this._productAvailabilityManager,
);
@override @override
String get name => 'add_free_packs'; String get name => 'add_free_packs';
@ -32,7 +38,10 @@ class AddFreePacks with task.Task {
for (final user in allUsers) { for (final user in allUsers) {
if (user.id == null) continue; if (user.id == null) continue;
final userPacks = await _userRepository.getUserPacks(user.id!); final userPacks = await _userRepository.getUserPacks(user.id!);
final userPackIds = userPacks.map((p) => p.id).whereType<String>().toSet(); final userPackIds = userPacks
.map((p) => p.id)
.whereType<String>()
.toSet();
// Если у пользователя нет хотя бы одного из freePacks // Если у пользователя нет хотя бы одного из freePacks
if (!freePackIds.every((packId) => userPackIds.contains(packId))) { if (!freePackIds.every((packId) => userPackIds.contains(packId))) {
@ -49,7 +58,7 @@ class AddFreePacks with task.Task {
try { try {
for (final pack in freePacks) { for (final pack in freePacks) {
if (pack.id == null) continue; if (pack.id == null) continue;
await _userRepository.grantPackAccess( await _productAvailabilityManager.grantPackAccess(
userId: userId, userId: userId,
packId: pack.id!, packId: pack.id!,
grantType: 'free', grantType: 'free',

View file

@ -27,7 +27,9 @@ class CheckAdminsTask with task.Task {
print('$name processing admin ID: $externalUserId'); print('$name processing admin ID: $externalUserId');
if (externalUserId.isNotEmpty) { if (externalUserId.isNotEmpty) {
// Найти токен по externalUserId // Найти токен по externalUserId
final rawUser = await _userRepository.getUserByExternalId(externalUserId); final rawUser = await _userRepository.getUserByExternalId(
externalUserId,
);
final userId = rawUser?.id; final userId = rawUser?.id;
if (userId == null) { if (userId == null) {

View file

@ -135,13 +135,18 @@ class PaymentDao extends DatabaseAccessor<AppDatabase>
/// Обновить статус платежа /// Обновить статус платежа
Future<void> updatePaymentStatus(String externalToken, String status) async { Future<void> updatePaymentStatus(String externalToken, String status) async {
print('🔍 PaymentDao.updatePaymentStatus: token=$externalToken, status=$status'); print(
final result = await (update(payments)..where((p) => p.externalToken.equals(externalToken))).write( '🔍 PaymentDao.updatePaymentStatus: token=$externalToken, status=$status',
PaymentsCompanion(
status: Value(status),
updatedAt: Value(PgDateTime(DateTime.now())),
),
); );
final result =
await (update(
payments,
)..where((p) => p.externalToken.equals(externalToken))).write(
PaymentsCompanion(
status: Value(status),
updatedAt: Value(PgDateTime(DateTime.now())),
),
);
print('✅ PaymentDao.updatePaymentStatus: result=$result'); print('✅ PaymentDao.updatePaymentStatus: result=$result');
} }

View file

@ -397,18 +397,11 @@ class UserDao extends DatabaseAccessor<AppDatabase> with _$UserDaoMixin {
/// Проверить, есть ли у пользователя доступ к паку /// Проверить, есть ли у пользователя доступ к паку
Future<bool> hasPackAccess(String userId, String packId) async { Future<bool> hasPackAccess(String userId, String packId) async {
print('🔍 UserDao.hasPackAccess: userId=$userId, packId=$packId');
print(
'🔍 UserDao.hasPackAccess: SQL: SELECT * FROM user_packs WHERE user_id = ? AND pack_id = ? LIMIT 1',
);
final query = select(userPacks) final query = select(userPacks)
..where((up) => up.userId.equals(userId) & up.packId.equals(packId)) ..where((up) => up.userId.equals(userId) & up.packId.equals(packId))
..limit(1); ..limit(1);
final results = await query.get(); final results = await query.get();
print(
'✅ UserDao.hasPackAccess: Result count=${results.length}, hasAccess=${results.isNotEmpty}',
);
return results.isNotEmpty; return results.isNotEmpty;
} }

View file

@ -8,6 +8,7 @@ import 'package:mnemo_cards_backend/cron/cron_executor.dart';
import 'package:mnemo_cards_backend/cron/generate_promocodes.dart'; import 'package:mnemo_cards_backend/cron/generate_promocodes.dart';
import 'package:mnemo_cards_backend/database/database.dart'; import 'package:mnemo_cards_backend/database/database.dart';
import 'package:mnemo_cards_backend/discounts/discounts_manager.dart'; import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
import 'package:mnemo_cards_backend/user/user_manager.dart'; import 'package:mnemo_cards_backend/user/user_manager.dart';
import 'package:mnemo_cards_backend/tests/test_manager.dart'; import 'package:mnemo_cards_backend/tests/test_manager.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/user/user_repository.dart';
@ -123,7 +124,11 @@ void main() async {
DeleteOldArchives(), DeleteOldArchives(),
CheckAdminsTask(getIt.get<UserRepository>()), CheckAdminsTask(getIt.get<UserRepository>()),
getIt.get<CheckPaymentTask>(), getIt.get<CheckPaymentTask>(),
AddFreePacks(getIt.get<FreePacksDistributor>(), getIt.get<UserRepository>()), AddFreePacks(
getIt.get<FreePacksDistributor>(),
getIt.get<UserRepository>(),
getIt.get<ProductAvailabilityManager>(),
),
Backup(backupDir), Backup(backupDir),
GeneratePromocodes(database), GeneratePromocodes(database),
DiscountCampaignTask(getIt.get<DiscountsManager>(), database), DiscountCampaignTask(getIt.get<DiscountsManager>(), database),

View file

@ -65,7 +65,6 @@ extension CardPackToDto on CardPack {
} }
extension CardPackToModel on CardPack { extension CardPackToModel on CardPack {
Future<CardPackModel> toModel() async { Future<CardPackModel> toModel() async {
return CardPackModel( return CardPackModel(
id: id, id: id,

View file

@ -1,26 +1,24 @@
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
import 'package:mnemo_cards_backend/user/user_repository.dart'; import 'package:mnemo_cards_backend/packs/product_availability_manager.dart';
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
import 'pack_repository.dart'; import 'pack_repository.dart';
@lazySingleton @lazySingleton
class FreePacksDistributor { class FreePacksDistributor {
final UserRepository _userRepository; final ProductAvailabilityManager _productAvailabilityManager;
final PackRepository _packRepository; final PackRepository _packRepository;
FreePacksDistributor(this._userRepository, this._packRepository); FreePacksDistributor(this._productAvailabilityManager, this._packRepository);
Future<List<CardPackModel>> getFreePacks() async { Future<List<CardPackModel>> getFreePacks() async {
final allPacks = await _packRepository.getAllPacks(enabledOnly: true); final allPacks = await _packRepository.getAllPacks(enabledOnly: true);
// Фильтруем паки с нулевой ценой или без цены // Фильтруем паки с нулевой ценой или без цены
return allPacks return allPacks.where((pack) {
.where((pack) { final priceStr = pack.price;
final priceStr = pack.price; if (priceStr == null || priceStr.isEmpty) return true;
if (priceStr == null || priceStr.isEmpty) return true; final price = double.tryParse(priceStr);
final price = double.tryParse(priceStr); return price == null || price == 0;
return price == null || price == 0; }).toList();
})
.toList();
} }
Future<void> giveFreePacksToUser(UserModel user) async { Future<void> giveFreePacksToUser(UserModel user) async {
@ -29,12 +27,15 @@ class FreePacksDistributor {
await givePacksToUser(user, packs); await givePacksToUser(user, packs);
} }
Future<void> givePacksToUser(UserModel user, List<CardPackModel> packs) async { Future<void> givePacksToUser(
UserModel user,
List<CardPackModel> packs,
) async {
if (user.id == null) return; if (user.id == null) return;
for (final pack in packs) { for (final pack in packs) {
if (pack.id == null) continue; if (pack.id == null) continue;
await _userRepository.grantPackAccess( await _productAvailabilityManager.grantPackAccess(
userId: user.id!, userId: user.id!,
packId: pack.id!, packId: pack.id!,
grantType: 'free', grantType: 'free',

View file

@ -20,11 +20,9 @@ class PackDtoConverter {
CardPackModel model, CardPackModel model,
UserModel? userModel, UserModel? userModel,
) async { ) async {
// Check access via purchases list (source of truth is user_packs table in DB) bool available =
// user.purchases is a cached list of pack IDs the user has access to userModel != null &&
bool available = userModel != null && (userModel.packs.contains(model.id?.toString()) || userModel.admin);
(userModel.purchases.contains(model.id?.toString()) ||
userModel.admin);
if (userModel != null && !available) { if (userModel != null && !available) {
available = available =
userModel.subscriptionModel?.features.contains( userModel.subscriptionModel?.features.contains(

View file

@ -235,4 +235,3 @@ class PackRepository {
await _db.packDao.updateVoiceUrl(voiceId, voiceUrl); await _db.packDao.updateVoiceUrl(voiceId, voiceUrl);
} }
} }

View file

@ -119,7 +119,9 @@ class UserManager {
String? name, String? name,
}) async { }) async {
// Check if user exists by externalId // Check if user exists by externalId
var existingUserModel = await _userRepository.getUserByExternalId(externalId); var existingUserModel = await _userRepository.getUserByExternalId(
externalId,
);
if (existingUserModel != null) { if (existingUserModel != null) {
print('User found $name $email $telegram'); print('User found $name $email $telegram');
@ -134,7 +136,9 @@ class UserManager {
if (shouldUpdateEmail || shouldUpdateTelegram) { if (shouldUpdateEmail || shouldUpdateTelegram) {
final updatedModel = existingUserModel.copyWith( final updatedModel = existingUserModel.copyWith(
email: shouldUpdateEmail ? email : existingUserModel.email, email: shouldUpdateEmail ? email : existingUserModel.email,
telegram: shouldUpdateTelegram ? telegram : existingUserModel.telegram, telegram: shouldUpdateTelegram
? telegram
: existingUserModel.telegram,
); );
await _userRepository.updateUser(updatedModel); await _userRepository.updateUser(updatedModel);
existingUserModel = updatedModel; existingUserModel = updatedModel;
@ -169,7 +173,10 @@ class UserManager {
userData: userDataCompanion, userData: userDataCompanion,
); );
final createdUserModel = await _userRepository.getUserById(userId, includePacks: true); final createdUserModel = await _userRepository.getUserById(
userId,
includePacks: true,
);
if (createdUserModel == null) { if (createdUserModel == null) {
throw Exception('Failed to create user'); throw Exception('Failed to create user');
} }

View file

@ -57,7 +57,7 @@ class UserRepository {
/// Получить пользователя по externalUserId /// Получить пользователя по externalUserId
Future<UserModel?> getUserByExternalId(String externalId) async { Future<UserModel?> getUserByExternalId(String externalId) async {
final user = await _db.userDao.getUserByExternalId(externalId); final user = await _db.userDao.getUserByExternalId(externalId);
if (user == null) return null; if (user == null) return null;
return await user.toUserModel(); return await user.toUserModel();
@ -141,29 +141,6 @@ class UserRepository {
return await Future.wait(packs.map((p) => p.toModel())); return await Future.wait(packs.map((p) => p.toModel()));
} }
/// Проверить, есть ли у пользователя доступ к паку
Future<bool> hasPackAccess(String userId, String packId) async {
return await _db.userDao.hasPackAccess(userId, packId);
}
/// Дать пользователю доступ к паку
Future<void> grantPackAccess({
required String userId,
required String packId,
String grantType = 'purchase',
}) async {
await _db.userDao.grantPackAccess(
userId: userId,
packId: packId,
grantType: grantType,
);
}
/// Отозвать доступ к паку
Future<void> revokePackAccess(String userId, String packId) async {
await _db.userDao.revokePackAccess(userId, packId);
}
/// Получить UserData пользователя /// Получить UserData пользователя
Future<UserData?> getUserData(String userId) async { Future<UserData?> getUserData(String userId) async {
return await _db.userDao.getUserData(userId); return await _db.userDao.getUserData(userId);
@ -179,4 +156,3 @@ class UserRepository {
await _db.userDao.updateLastOnline(userId); await _db.userDao.updateLastOnline(userId);
} }
} }