admin
Some checks are pending
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Web App CI / test (push) Waiting to run
Web App 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-20 13:35:54 +03:00
parent 1d9bc6c43d
commit 6d499dc584
6 changed files with 73 additions and 17 deletions

View file

@ -150,6 +150,7 @@ class PacksApiV2 {
}
/// Generates presigned URL if value is a UUID (object ID), otherwise returns null
/// Presigned URLs now have 7 days expiration (configured in MinioConfig.presignedUrlExpirySeconds)
Future<String?> _getPresignedUrlIfUuid(String? value, String bucket) async {
if (value == null || value.isEmpty) return null;
if (!_isValidUuid(value)) return null;
@ -394,7 +395,8 @@ class PacksApiV2 {
allVoices.addAll(voices);
}
// Convert to DTOs and generate presigned URLs
// Convert to DTOs and generate presigned URLs for images
// Presigned URLs now have 7 days expiration to avoid 403 errors on cached data
final cardDtos = await Future.wait(
paginatedCards.map((card) async {
final dto = await card.toDto(

View file

@ -15,8 +15,9 @@ class MinioConfig {
static const String cardImagesBucket = 'card-images';
static const String voiceAudioBucket = 'voice-audio';
// Presigned URL expiration (4 hours)
static const int presignedUrlExpirySeconds = 4 * 60 * 60;
// Presigned URL expiration (7 days)
// Increased from 4 hours to prevent 403 errors on cached card data
static const int presignedUrlExpirySeconds = 7 * 24 * 60 * 60;
MinioConfig({
required this.endpoint,

View file

@ -251,6 +251,16 @@ class HttpRepositoryV2 {
originalError: error,
);
break;
case 409:
// Conflict - typically means resource already exists (e.g., pack already purchased)
apiException = ServerException(
message: message.isNotEmpty
? message
: 'Conflict: This resource already exists or is already purchased',
statusCode: 409,
originalError: error,
);
break;
case null:
apiException = NetworkException(
message: message.isNotEmpty ? message : 'Network error',
@ -1059,7 +1069,10 @@ class HttpRepositoryV2 {
}
/// Get pack purchase details (includes ad reward offers)
Future<CardPackBuyDto?> getPackBuy(String packId) async {
/// Returns a tuple with CardPackBuyDto and isPurchased flag
Future<({CardPackBuyDto packInfo, bool isPurchased})?> getPackBuy(
String packId,
) async {
try {
final response = await _dio.get<Map<String, dynamic>>(
ApiConfigV2.packBuy(packId),
@ -1068,7 +1081,9 @@ class HttpRepositoryV2 {
if (data == null) {
return null;
}
return CardPackBuyDto.fromJson(data);
final packInfo = CardPackBuyDto.fromJson(data);
final isPurchased = data['isPurchased'] as bool? ?? false;
return (packInfo: packInfo, isPurchased: isPurchased);
} on DioException catch (e) {
if (e.response?.statusCode == 404) {
return null;

View file

@ -13,7 +13,10 @@ class PurchasesService {
final HttpRepositoryV2 _httpRepository;
/// Get pack purchase information (includes preview cards, price, items)
Future<CardPackBuyDto?> getPackBuy(String packId) async {
/// Returns a tuple with CardPackBuyDto and isPurchased flag
Future<({CardPackBuyDto packInfo, bool isPurchased})?> getPackBuy(
String packId,
) async {
log('Getting pack buy info for $packId', name: 'PurchasesService');
try {
return await _httpRepository.getPackBuy(packId);

View file

@ -49,16 +49,26 @@ class PurchaseStateManager extends StateManager<PurchaseState> {
emit(const PurchaseState.loading());
try {
final packInfo = await _purchasesService.getPackBuy(packId);
final result = await _purchasesService.getPackBuy(packId);
if (packInfo == null) {
if (result == null) {
emit(const PurchaseState.error(
message: 'Pack not found or not available for purchase',
));
return;
}
emit(PurchaseState.loaded(packInfo: packInfo));
// Check if pack is already purchased
if (result.isPurchased) {
emit(PurchaseState.completed(
packInfo: result.packInfo,
message: 'This pack is already purchased. You already have access to it.',
));
log('Pack is already purchased', name: 'PurchaseStateManager');
return;
}
emit(PurchaseState.loaded(packInfo: result.packInfo));
log('Pack purchase info loaded', name: 'PurchaseStateManager');
} catch (e, s) {
log(

View file

@ -9,6 +9,7 @@ import 'package:yx_state_flutter/yx_state_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../di/app_scope/app_scope_container.dart';
import '../../../domain/exceptions/api_exception.dart';
import '../../../domain/state/purchase_state_manager.dart';
import '../../../utils/color_extension.dart';
import '../../widgets/error_view.dart';
@ -119,8 +120,26 @@ class _PurchasePageState extends State<PurchasePage> {
name: 'PurchasePage',
);
if (mounted) {
String errorMessage = 'Purchase error: ${e.toString()}';
// Handle specific error cases
if (e is ServerException && e.statusCode == 409) {
errorMessage = 'This pack is already purchased. You already have access to it.';
} else if (e is ValidationException) {
errorMessage = e.message;
} else if (e is ServerException) {
errorMessage = e.message.isNotEmpty
? e.message
: 'Server error occurred. Please try again.';
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Purchase error: ${e.toString()}')),
SnackBar(
content: Text(errorMessage),
backgroundColor: e is ServerException && e.statusCode == 409
? Colors.orange
: Colors.red,
),
);
}
} finally {
@ -233,7 +252,7 @@ class _PurchasePageState extends State<PurchasePage> {
final theme = Theme.of(context);
final packColor = packInfo.color?.asColor ?? theme.colorScheme.primary;
return Expanded(
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@ -243,12 +262,18 @@ class _PurchasePageState extends State<PurchasePage> {
// Preview cards
if (packInfo.cards.isNotEmpty) _PreviewCards(cards: packInfo.cards),
Expanded(
child: Column(
children: [
...?packInfo.items?.build(),
],),
),
// Items (description, features, etc.)
if (packInfo.items != null && packInfo.items!.isNotEmpty)
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...packInfo.items!.build(),
],
),
),
// Purchase button
Padding(
padding: const EdgeInsets.all(16.0),