diff --git a/lang/en/query.php b/lang/en/query.php index ceb7a0d..19c30f0 100644 --- a/lang/en/query.php +++ b/lang/en/query.php @@ -5,4 +5,5 @@ 'get_beams.description' => 'Get beams details.', 'get_single_use_codes.description' => 'Get single use codes.', 'get_claims.description' => 'Get the claims details.', + 'get_pending_claims.description' => 'Get a list of pending claims for a Beam.', ]; diff --git a/src/GraphQL/Queries/GetPendingClaimsQuery.php b/src/GraphQL/Queries/GetPendingClaimsQuery.php new file mode 100644 index 0000000..f8fe6e3 --- /dev/null +++ b/src/GraphQL/Queries/GetPendingClaimsQuery.php @@ -0,0 +1,80 @@ + 'GetPendingClaims', + 'description' => __('enjin-platform-beam::query.get_pending_claims.description'), + ]; + } + + /** + * Get the query's return type. + */ + public function type(): Type + { + return GraphQL::paginate('BeamClaim', 'BeamClaimConnection'); + } + + /** + * Get the query's arguments definition. + */ + public function args(): array + { + return ConnectionInput::args([ + 'code' => [ + 'type' => GraphQL::type('String!'), + 'description' => __('enjin-platform-beam::mutation.claim_beam.args.code'), + ], + 'account' => [ + 'type' => GraphQL::type('String!'), + 'description' => __('enjin-platform-beam::mutation.claim_beam.args.account'), + ], + ]); + } + + /** + * Resolve the query's request. + */ + public function resolve( + $root, + array $args, + $context, + ResolveInfo $resolveInfo, + Closure $getSelectFields + ) { + return BeamClaim::loadSelectFields($resolveInfo, $this->name) + ->hasCode(Arr::get($args, 'code')) + ->where('wallet_public_key', SS58Address::getPublicKey(Arr::get($args, 'account'))) + ->cursorPaginateWithTotalDesc('id', $args['first']); + } + + /** + * Get the query's request validation rules. + */ + protected function rules(array $args = []): array + { + return [ + 'code' => ['filled', 'max:1024'], + 'account' => ['filled', new ValidSubstrateAccount()], + ]; + } +} diff --git a/src/GraphQL/Types/BeamClaimType.php b/src/GraphQL/Types/BeamClaimType.php index 650fb79..3f1f742 100644 --- a/src/GraphQL/Types/BeamClaimType.php +++ b/src/GraphQL/Types/BeamClaimType.php @@ -76,7 +76,7 @@ public function fields(): array 'type' => GraphQL::type('String!'), 'description' => __('enjin-platform-beam::type.beam_claim.field.code'), 'resolve' => fn ($claim) => $claim->code ? $claim->singleUseCode : '', - 'excludeFrom' => ['GetBeam', 'GetBeams'], + 'excludeFrom' => ['GetBeam', 'GetBeams', 'GetPendingClaims'], ], 'identifierCode' => [ 'type' => GraphQL::type('String!'), @@ -94,7 +94,16 @@ public function fields(): array }, 'selectable' => false, 'is_relation' => false, - 'excludeFrom' => ['GetBeam', 'GetBeams'], + 'excludeFrom' => ['GetBeam', 'GetBeams', 'GetPendingClaims'], + ], + 'attributes' => [ + 'type' => GraphQL::type('[AttributeType]'), + 'description' => __('enjin-platform-beam::type.attribute.description'), + ], + 'transaction' => [ + 'type' => GraphQL::type('Transaction'), + 'description' => __('enjin-platform::type.transaction.description'), + 'is_relation' => true, ], 'attributes' => [ 'type' => GraphQL::type('[AttributeType]'), diff --git a/src/Jobs/ClaimBeam.php b/src/Jobs/ClaimBeam.php index 393e25f..fef8b05 100644 --- a/src/Jobs/ClaimBeam.php +++ b/src/Jobs/ClaimBeam.php @@ -18,6 +18,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Throwable; class ClaimBeam implements ShouldQueue @@ -56,10 +57,17 @@ public function handle(BatchService $batch, WalletService $wallet): void BeamScan::firstWhere(['wallet_public_key' => $data['wallet_public_key'], 'beam_id' => $data['beam']['id']])?->delete(); DB::commit(); + + Log::info('Claim beam assigned.', $data); + } else { + Log::info('Claim beam cannot assign.', $data); + $this->release(1); } } catch (Throwable $e) { DB::rollBack(); + Log::error('Claim beam error, message:' . $e->getMessage(), $data); + throw $e; } } diff --git a/src/Models/Laravel/BeamClaim.php b/src/Models/Laravel/BeamClaim.php index 150e0f2..ececf87 100644 --- a/src/Models/Laravel/BeamClaim.php +++ b/src/Models/Laravel/BeamClaim.php @@ -9,6 +9,7 @@ use Enjin\Platform\Models\BaseModel; use Enjin\Platform\Models\Laravel\Collection; use Enjin\Platform\Models\Laravel\Token; +use Enjin\Platform\Models\Laravel\Transaction; use Enjin\Platform\Models\Laravel\Wallet; use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -16,6 +17,7 @@ use Illuminate\Database\Eloquent\MassPrunable; use Illuminate\Database\Eloquent\Prunable; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOneThrough; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; @@ -194,6 +196,21 @@ public function prunable() return static::where('id', 0); } + /** + * The transaction relationship. + */ + public function transaction(): HasOneThrough + { + return $this->hasOneThrough( + Transaction::class, + BeamBatch::class, + 'id', + 'id', + 'beam_batch_id', + 'transaction_id' + ); + } + /** * This model's factory. */ diff --git a/src/Models/Laravel/Traits/EagerLoadSelectFields.php b/src/Models/Laravel/Traits/EagerLoadSelectFields.php index 3c51d46..4bd71f8 100644 --- a/src/Models/Laravel/Traits/EagerLoadSelectFields.php +++ b/src/Models/Laravel/Traits/EagerLoadSelectFields.php @@ -39,6 +39,7 @@ public static function selectFields(ResolveInfo $resolveInfo, string $query): ar ); break; + case 'GetPendingClaims': case 'GetClaims': case 'GetSingleUseCodes': [$select, $with, $withCount] = static::loadClaims( @@ -128,6 +129,7 @@ public static function loadClaims( 'beam_id', isset($fields['wallet']) ? 'wallet_public_key' : null, isset($fields['collection']) ? 'collection_id' : null, + isset($fields['transaction']) ? 'beam_batch_id' : null, ...(isset($fields['qr']) ? ['code'] : []), ...(static::$query == 'GetSingleUseCodes' ? ['code', 'nonce'] : ['nonce']), ...BeamClaimType::getSelectFields($fieldKeys = array_keys($fields)), diff --git a/src/Services/BeamService.php b/src/Services/BeamService.php index 69d6ca4..3b87f45 100644 --- a/src/Services/BeamService.php +++ b/src/Services/BeamService.php @@ -29,6 +29,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; use Illuminate\Support\LazyCollection; use Illuminate\Support\Str; use Throwable; @@ -213,6 +214,7 @@ public function claim(string $code, string $wallet): bool ClaimBeam::dispatch($claim = $this->buildClaimBeamData($wallet, $beam, $singleUseCode)); event(new BeamClaimPending($claim)); Cache::decrement($key); + Log::info("Claim beam: {$code}, Remaining: " . Cache::get($key), $claim); } catch (LockTimeoutException $e) { throw new BeamException(__('enjin-platform-beam::error.unable_to_process')); } finally { diff --git a/src/Support/ClaimProbabilities.php b/src/Support/ClaimProbabilities.php index 61e5d8e..d289b40 100644 --- a/src/Support/ClaimProbabilities.php +++ b/src/Support/ClaimProbabilities.php @@ -2,16 +2,21 @@ namespace Enjin\Platform\Beam\Support; +use Closure; use Enjin\Platform\Beam\Enums\PlatformBeamCache; +use Enjin\Platform\Beam\Models\Laravel\BeamClaim; use Enjin\Platform\Beam\Rules\Traits\IntegerRange; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; +use stdClass; class ClaimProbabilities { use IntegerRange; + public const FORMAT_VERSION = 'v2'; + /** * Create or update the probabilities for a claim. */ @@ -31,7 +36,15 @@ public function createOrUpdateProbabilities(string $code, array $claims): void */ public static function hasProbabilities(string $code): bool { - return Cache::has(PlatformBeamCache::CLAIM_PROBABILITIES->key($code)); + return Cache::has(static::getCacheKey($code)); + } + + /** + * Get the cache key for the code. + */ + public static function getCacheKey(string $code): string + { + return PlatformBeamCache::CLAIM_PROBABILITIES->key($code) . ':' . static::FORMAT_VERSION; } /** @@ -39,7 +52,41 @@ public static function hasProbabilities(string $code): bool */ public static function getProbabilities(string $code): array { - return Cache::get(PlatformBeamCache::CLAIM_PROBABILITIES->key($code), []); + return Cache::get( + static::getCacheKey($code), + static::getProbabilitiesFromDB($code) + ); + } + + /** + * Get the probabilities from the database. + */ + public static function getProbabilitiesFromDB(string $code): Closure + { + return function () use ($code) { + $claims = BeamClaim::selectRaw(' + token_chain_id, + quantity as tokenQuantityPerClaim, + count(*) as claimQuantity + ')->hasCode($code) + ->groupBy('token_chain_id', 'quantity') + ->get() + ->map(function ($claim) { + $claim->tokenIds = [$claim->token_chain_id]; + + return $claim; + })->toArray(); + + $instance = resolve(static::class); + $formatted = $instance->filterClaims($claims); + $instance->computeProbabilities( + $code, + $formatted['ft'], + $formatted['nft'], + ); + + return Cache::get(static::getCacheKey($code), []); + }; } /** @@ -68,6 +115,34 @@ public function removeTokens(string $code, array $tokenIds): void } } + /** + * Compute the probabilities for the items. + */ + public function computeProbabilities(string $code, array $fts, array $nfts): void + { + $total = collect($fts)->sum() + ($totalNft = collect($nfts)->sum()); + if (!$total) { + return; + } + + $probabilities = [ + 'ft' => collect($fts)->mapWithKeys(fn ($quantity, $key) => [$key => ($quantity / $total) * 100])->toArray() ?: new stdClass(), + 'nft' => ($totalNft / $total) * 100, + 'ftTokenIds' => $this->extractTokenIds($fts, $total) ?: new stdClass(), + 'nftTokenIds' => $this->extractTokenIds($nfts, $total) ?: new stdClass(), + ]; + + $data = [ + 'tokens' => ['ft' => $fts, 'nft' => $nfts], + 'probabilities' => $probabilities, + ]; + + Cache::forever( + static::getCacheKey($code), + $data + ); + } + /** * Merge the tokens into the current tokens. */ @@ -116,28 +191,22 @@ protected function filterClaims(array $claims): array } /** - * Compute the probabilities for the items. + * Extract the token ids from the array. */ - protected function computeProbabilities(string $code, array $fts, array $nfts): void + protected function extractTokenIds(array $tokenIds, int $total): array { - $totalNft = collect($nfts)->sum(); - $total = collect($fts)->sum() + $totalNft; - $probabilities = []; - if ($total > 0) { - foreach ($fts as $key => $quantity) { - $probabilities['ft'][$key] = ($quantity / $total) * 100; + $tokens = []; + foreach ($tokenIds as $key => $quantity) { + if (($range = $this->integerRange($key)) !== false) { + $count = $quantity / (($range[1] - $range[0]) + 1); + for ($i = $range[0]; $i <= $range[1]; $i++) { + $tokens[$i] = ($count / $total) * 100; + } + } else { + $tokens[$key] = ($quantity / $total) * 100; } - $probabilities['nft'] = ($totalNft / $total) * 100; } - $data = [ - 'tokens' => ['ft' => $fts, 'nft' => $nfts], - 'probabilities' => $probabilities, - ]; - - Cache::forever( - PlatformBeamCache::CLAIM_PROBABILITIES->key($code), - $data - ); + return $tokens; } } diff --git a/tests/Feature/GraphQL/Queries/GetPendingClaimsTest.php b/tests/Feature/GraphQL/Queries/GetPendingClaimsTest.php new file mode 100644 index 0000000..036d82e --- /dev/null +++ b/tests/Feature/GraphQL/Queries/GetPendingClaimsTest.php @@ -0,0 +1,76 @@ +seedBeam(10, true); + } + + /** + * Test get claims. + */ + public function test_it_can_get_pending_claims(): void + { + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'account' => $this->claims->first()->wallet_public_key, + ]); + $this->assertNotEmpty($response['totalCount']); + + $response = $this->graphql($this->method, [ + 'code' => $this->beam->code, + 'account' => resolve(SubstrateProvider::class)->public_key(), + ]); + $this->assertEmpty($response['edges']); + } + + /** + * Test get claims with id and code. + */ + public function test_will_fail_with_invalid_parameters(): void + { + $response = $this->graphql($this->method, [ + 'code' => null, + 'account' => null, + ], true); + $this->assertArraySubset([ + ['message' => 'Variable "$code" of non-null type "String!" must not be null.'], + ['message' => 'Variable "$account" of non-null type "String!" must not be null.'], + ], $response['errors']); + + $response = $this->graphql($this->method, [ + 'code' => '', + 'account' => '', + ], true); + $this->assertArraySubset([ + 'code' => ['The code field must have a value.'], + 'account' => ['The account field must have a value.'], + ], $response['error']); + + $response = $this->graphql($this->method, [ + 'code' => fake()->text(), + 'account' => fake()->text(), + ], true); + $this->assertArraySubset([ + 'account' => ['The account is not a valid substrate account.'], + ], $response['error']); + } +} diff --git a/tests/Feature/GraphQL/Resources/GetClaims.graphql b/tests/Feature/GraphQL/Resources/GetClaims.graphql index 2f33690..f27bc61 100644 --- a/tests/Feature/GraphQL/Resources/GetClaims.graphql +++ b/tests/Feature/GraphQL/Resources/GetClaims.graphql @@ -26,6 +26,9 @@ query GetClaims( key value } + transaction { + transactionHash + } wallet { account { publicKey diff --git a/tests/Feature/GraphQL/Resources/GetPendingClaims.graphql b/tests/Feature/GraphQL/Resources/GetPendingClaims.graphql new file mode 100644 index 0000000..5ed83db --- /dev/null +++ b/tests/Feature/GraphQL/Resources/GetPendingClaims.graphql @@ -0,0 +1,78 @@ +query GetPendingClaims( + $code: String! + $account: String! + $after: String + $first: Int +) { + GetPendingClaims( + code: $code + account: $account + after: $after + first: $first + ) { + edges { + cursor + node { + id + claimedAt + claimStatus + quantity + identifierCode + attributes { + key + value + } + wallet { + account { + publicKey + address + } + } + transaction { + transactionHash + } + collection { + collectionId + maxTokenCount + maxTokenSupply + forceSingleMint + frozen + network + } + beam { + id + code + name + description + image + start + end + isClaimable + qr { + url + payload + } + message { + walletPublicKey + message + } + collection { + collectionId + maxTokenCount + maxTokenSupply + forceSingleMint + frozen + network + } + } + } + } + totalCount + pageInfo { + startCursor + endCursor + hasPreviousPage + hasNextPage + } + } +} diff --git a/tests/Unit/ClaimProbabilityTest.php b/tests/Unit/ClaimProbabilityTest.php index 423b74f..2afcbef 100644 --- a/tests/Unit/ClaimProbabilityTest.php +++ b/tests/Unit/ClaimProbabilityTest.php @@ -30,11 +30,24 @@ public function test_it_can_create_probabilities() $this->assertEquals( [ 'ft' => [ - '41' => 10, - '42' => 20, - '43..45' => 30, + '7' => 10.0, + '8..10' => 60.0, + ], + 'nft' => 30.0, + 'ftTokenIds' => [ + '7' => 10.0, + '8' => 20.0, + '9' => 20.0, + '10' => 20.0, + ], + 'nftTokenIds' => [ + '1' => 5.0, + '2' => 5.0, + '3' => 5.0, + '4' => 5.0, + '5' => 5.0, + '6' => 5.0, ], - 'nft' => 40, ], ClaimProbabilities::getProbabilities($this->beam->code)['probabilities'] ); @@ -46,20 +59,46 @@ public function test_it_can_remove_tokens() $this->assertEquals( [ 'ft' => [ - '41' => 10, - '42' => 20, - '43..45' => 30, + '7' => 10.0, + '8..10' => 60.0, + ], + 'nft' => 30.0, + 'ftTokenIds' => [ + '7' => 10.0, + '8' => 20.0, + '9' => 20.0, + '10' => 20.0, + ], + 'nftTokenIds' => [ + '1' => 5.0, + '2' => 5.0, + '3' => 5.0, + '4' => 5.0, + '5' => 5.0, + '6' => 5.0, ], - 'nft' => 40, ], ClaimProbabilities::getProbabilities($this->beam->code)['probabilities'] ); - $this->probabilities->removeTokens($this->beam->code, ['42', '43..45']); + $this->probabilities->removeTokens($this->beam->code, ['8..10']); $this->assertEquals( [ - 'ft' => ['41' => 20], - 'nft' => 80, + 'ft' => [ + '7' => 25.0, + ], + 'nft' => 75.0, + 'ftTokenIds' => [ + '7' => 25.0, + ], + 'nftTokenIds' => [ + '1' => 12.5, + '2' => 12.5, + '3' => 12.5, + '4' => 12.5, + '5' => 12.5, + '6' => 12.5, + ], ], ClaimProbabilities::getProbabilities($this->beam->code)['probabilities'] ); @@ -70,26 +109,26 @@ protected function generateTokens(): array return [ [ 'type' => BeamType::MINT_ON_DEMAND->name, - 'tokenIds' => ['1..40'], + 'tokenIds' => ['1..5'], 'claimQuantity' => 1, 'tokenQuantityPerClaim' => 1, ], [ 'type' => BeamType::MINT_ON_DEMAND->name, - 'tokenIds' => ['41'], - 'claimQuantity' => 10, + 'tokenIds' => ['6'], + 'claimQuantity' => 1, 'tokenQuantityPerClaim' => 1, ], [ 'type' => BeamType::MINT_ON_DEMAND->name, - 'tokenIds' => ['42'], - 'claimQuantity' => 20, + 'tokenIds' => ['7'], + 'claimQuantity' => 2, 'tokenQuantityPerClaim' => 1, ], [ 'type' => BeamType::MINT_ON_DEMAND->name, - 'tokenIds' => ['43..45'], - 'claimQuantity' => 10, + 'tokenIds' => ['8..10'], + 'claimQuantity' => 4, 'tokenQuantityPerClaim' => 1, ], ];