refactor: extract Actions for CheckAvailability, SubmitClaim, RegisterName
CorePHP Actions pattern — single-purpose classes with static ::run(). Controller methods now delegate to Actions. Each Action validates, executes, and returns typed results. Enables reuse from commands, jobs, and tests without going through HTTP. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2b91476cf7
commit
ca11c4ccee
6 changed files with 227 additions and 52 deletions
51
app/Mod/Names/Actions/CheckAvailability.php
Normal file
51
app/Mod/Names/Actions/CheckAvailability.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Names\Actions;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Mod\Chain\Services\DaemonRpc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a .lthn name is available for registration.
|
||||||
|
*
|
||||||
|
* $result = CheckAvailability::run('mybrand');
|
||||||
|
* if ($result['available']) { ... }
|
||||||
|
*/
|
||||||
|
class CheckAvailability
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DaemonRpc $rpc,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(string $name): array
|
||||||
|
{
|
||||||
|
$name = strtolower(trim($name));
|
||||||
|
|
||||||
|
if (! preg_match('/^[a-z0-9][a-z0-9.\-]{0,254}$/', $name)) {
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'available' => false,
|
||||||
|
'reserved' => false,
|
||||||
|
'reason' => 'Invalid name format.',
|
||||||
|
'fqdn' => "{$name}.lthn",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$alias = $this->rpc->getAliasByName($name);
|
||||||
|
$reserved = Cache::has("name_lock:{$name}");
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'available' => $alias === null && ! $reserved,
|
||||||
|
'reserved' => $reserved,
|
||||||
|
'fqdn' => "{$name}.lthn",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function run(string $name): array
|
||||||
|
{
|
||||||
|
return app(static::class)->handle($name);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
app/Mod/Names/Actions/RegisterName.php
Normal file
90
app/Mod/Names/Actions/RegisterName.php
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Names\Actions;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Mod\Chain\Services\DaemonRpc;
|
||||||
|
use Mod\Chain\Services\WalletRpc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a .lthn name on the blockchain.
|
||||||
|
*
|
||||||
|
* $result = RegisterName::run('mybrand', '');
|
||||||
|
* echo $result['ticket_id'];
|
||||||
|
*/
|
||||||
|
class RegisterName
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DaemonRpc $rpc,
|
||||||
|
private readonly WalletRpc $wallet,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(string $name, string $address = ''): array
|
||||||
|
{
|
||||||
|
$name = strtolower(trim($name));
|
||||||
|
|
||||||
|
$this->validate($name);
|
||||||
|
|
||||||
|
// Atomic reservation lock (10 min)
|
||||||
|
$lockKey = "name_lock:{$name}";
|
||||||
|
if (! Cache::add($lockKey, true, 600)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'name' => 'Name is being registered by another request.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use registrar wallet if no address provided
|
||||||
|
if (empty($address)) {
|
||||||
|
$address = $this->wallet->getAddress();
|
||||||
|
}
|
||||||
|
|
||||||
|
$comment = 'v=lthn1;type=user';
|
||||||
|
$result = $this->wallet->registerAlias($name, $address, $comment);
|
||||||
|
$ticketId = bin2hex(random_bytes(6));
|
||||||
|
|
||||||
|
if (isset($result['tx_id'])) {
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'fqdn' => "{$name}.lthn",
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'tx_id' => $result['tx_id'],
|
||||||
|
'status' => 'pending',
|
||||||
|
'address' => $address,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registration queued (chain busy)
|
||||||
|
Cache::forget($lockKey);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'fqdn' => "{$name}.lthn",
|
||||||
|
'ticket_id' => $ticketId,
|
||||||
|
'status' => 'queued',
|
||||||
|
'message' => $result['error'] ?? 'Queued for next block.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validate(string $name): void
|
||||||
|
{
|
||||||
|
if (! preg_match('/^[a-z0-9][a-z0-9.\-]{0,254}$/', $name) || strlen($name) < 6) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'name' => 'Invalid name. Use 6+ lowercase alphanumeric characters.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->rpc->getAliasByName($name) !== null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'name' => 'Name already registered.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function run(string $name, string $address = ''): array
|
||||||
|
{
|
||||||
|
return app(static::class)->handle($name, $address);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Mod/Names/Actions/SubmitClaim.php
Normal file
75
app/Mod/Names/Actions/SubmitClaim.php
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Names\Actions;
|
||||||
|
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Mod\Chain\Services\DaemonRpc;
|
||||||
|
use Mod\Names\Models\NameClaim;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a pre-registration name claim.
|
||||||
|
*
|
||||||
|
* $claim = SubmitClaim::run(['name' => 'mybrand', 'email' => 'me@example.com']);
|
||||||
|
* echo $claim->claim_id;
|
||||||
|
*/
|
||||||
|
class SubmitClaim
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DaemonRpc $rpc,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(array $data): NameClaim
|
||||||
|
{
|
||||||
|
$name = strtolower(trim($data['name'] ?? ''));
|
||||||
|
$email = trim($data['email'] ?? '');
|
||||||
|
|
||||||
|
$this->validate($name, $email);
|
||||||
|
|
||||||
|
return NameClaim::create([
|
||||||
|
'name' => $name,
|
||||||
|
'email' => $email,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validate(string $name, string $email): void
|
||||||
|
{
|
||||||
|
if (! preg_match('/^[a-z0-9][a-z0-9.\-]{0,254}$/', $name)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'name' => 'Invalid name. Use lowercase alphanumeric characters.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($name) < 6) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'name' => 'Name must be at least 6 characters.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => 'Valid email required for claim notification.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not already registered on chain
|
||||||
|
if ($this->rpc->getAliasByName($name) !== null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'name' => 'Name already registered.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not already claimed
|
||||||
|
if (NameClaim::where('name', $name)->exists()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'name' => 'Name already claimed. Awaiting approval.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function run(array $data): NameClaim
|
||||||
|
{
|
||||||
|
return app(static::class)->handle($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Mod\Chain\Services\DaemonRpc;
|
use Mod\Chain\Services\DaemonRpc;
|
||||||
use Mod\Chain\Services\WalletRpc;
|
use Mod\Chain\Services\WalletRpc;
|
||||||
|
use Mod\Names\Actions;
|
||||||
use Mod\Names\Models\NameClaim;
|
use Mod\Names\Models\NameClaim;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -34,24 +35,7 @@ class NamesController extends Controller
|
||||||
*/
|
*/
|
||||||
public function available(string $name): JsonResponse
|
public function available(string $name): JsonResponse
|
||||||
{
|
{
|
||||||
$name = strtolower(trim($name));
|
return response()->json(Actions\CheckAvailability::run($name));
|
||||||
|
|
||||||
if (! $this->isValidName($name)) {
|
|
||||||
return response()->json([
|
|
||||||
'available' => false,
|
|
||||||
'reason' => 'Invalid name. Use 1-63 lowercase alphanumeric characters.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$alias = $this->rpc->getAliasByName($name);
|
|
||||||
$reserved = Cache::has("name_lock:{$name}");
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'name' => $name,
|
|
||||||
'available' => $alias === null && ! $reserved,
|
|
||||||
'reserved' => $reserved,
|
|
||||||
'fqdn' => "{$name}.lthn",
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -543,43 +527,14 @@ class NamesController extends Controller
|
||||||
*/
|
*/
|
||||||
public function claim(Request $request): JsonResponse
|
public function claim(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$name = strtolower(trim((string) $request->input('name')));
|
$claim = Actions\SubmitClaim::run($request->only(['name', 'email']));
|
||||||
$email = trim((string) $request->input('email'));
|
|
||||||
|
|
||||||
if (! $this->isValidName($name)) {
|
|
||||||
return response()->json(['error' => 'Invalid name. Use 6+ lowercase alphanumeric characters.'], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strlen($name) < 6) {
|
|
||||||
return response()->json(['error' => 'Name must be at least 6 characters.'], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
||||||
return response()->json(['error' => 'Valid email required for claim notification.'], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check not already registered on chain
|
|
||||||
$alias = $this->rpc->getAliasByName($name);
|
|
||||||
if ($alias !== null) {
|
|
||||||
return response()->json(['error' => 'Name already registered.', 'name' => $name], 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check not already claimed in database
|
|
||||||
if (NameClaim::where('name', $name)->exists()) {
|
|
||||||
return response()->json(['error' => 'Name already claimed. Awaiting approval.', 'name' => $name], 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
$claim = NameClaim::create([
|
|
||||||
'name' => $name,
|
|
||||||
'email' => $email,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'claim_id' => $claim->claim_id,
|
'claim_id' => $claim->claim_id,
|
||||||
'name' => $name,
|
'name' => $claim->name,
|
||||||
'fqdn' => "{$name}.lthn",
|
'fqdn' => "{$claim->name}.lthn",
|
||||||
'status' => 'pending',
|
'status' => $claim->status,
|
||||||
'message' => 'Your claim has been submitted. We will notify you at ' . $email . ' when approved.',
|
'message' => "Your claim has been submitted. We will notify you at {$claim->email} when approved.",
|
||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ class NameClaim extends Model
|
||||||
'status',
|
'status',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'status' => 'pending',
|
||||||
|
];
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::creating(function (self $claim) {
|
static::creating(function (self $claim) {
|
||||||
|
|
|
||||||
Binary file not shown.
Loading…
Add table
Reference in a new issue