Add platform admin hosting API for Ladill account provisioning.
Deploy Ladill Hosting / deploy (push) Successful in 1m29s
Deploy Ladill Hosting / deploy (push) Successful in 1m29s
Expose authenticated service endpoints for assigning, listing, renewing, and managing hosting accounts from the platform, with tests and deploy docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Hosting;
|
||||
|
||||
use App\Jobs\ProvisionHostingAccountJob;
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingProduct;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AdminHostingAssignmentService
|
||||
{
|
||||
public function __construct(
|
||||
private NodeCapacityService $nodeCapacity,
|
||||
private HostingResourcePolicyService $resourcePolicy,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function assign(User $owner, array $input): array
|
||||
{
|
||||
$product = HostingProduct::query()->findOrFail((int) $input['hosting_product_id']);
|
||||
|
||||
if (! $product->is_active) {
|
||||
throw ValidationException::withMessages([
|
||||
'hosting_product_id' => 'Selected hosting product is not active.',
|
||||
]);
|
||||
}
|
||||
|
||||
$username = isset($input['username']) && $input['username'] !== ''
|
||||
? (string) $input['username']
|
||||
: $this->generateUsername($owner);
|
||||
|
||||
if (HostingAccount::query()->where('username', $username)->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'username' => 'This username is already taken.',
|
||||
]);
|
||||
}
|
||||
|
||||
$primaryDomain = isset($input['primary_domain']) && $input['primary_domain'] !== ''
|
||||
? (string) $input['primary_domain']
|
||||
: null;
|
||||
|
||||
$accountType = match ($product->type) {
|
||||
'single_domain', 'multi_domain', 'wordpress' => HostingAccount::TYPE_SHARED,
|
||||
'vps' => HostingAccount::TYPE_VPS,
|
||||
'dedicated' => HostingAccount::TYPE_DEDICATED,
|
||||
'email_standalone' => HostingAccount::TYPE_SHARED,
|
||||
default => HostingAccount::TYPE_SHARED,
|
||||
};
|
||||
|
||||
$node = null;
|
||||
$nodeId = null;
|
||||
if ($accountType === HostingAccount::TYPE_SHARED) {
|
||||
$node = $this->nodeCapacity->findAvailableNodeForProduct($product);
|
||||
$nodeId = $node?->id;
|
||||
}
|
||||
|
||||
$resourceLimits = $this->resourcePolicy->defaultLimitsForProduct($product);
|
||||
$durationMonths = (int) $input['duration_months'];
|
||||
|
||||
$hostingAccount = HostingAccount::create([
|
||||
'user_id' => $owner->id,
|
||||
'hosting_product_id' => $product->id,
|
||||
'hosting_node_id' => $nodeId,
|
||||
'username' => $username,
|
||||
'primary_domain' => $primaryDomain,
|
||||
'type' => $accountType,
|
||||
'status' => HostingAccount::STATUS_PENDING,
|
||||
'allocated_disk_gb' => (int) ($product->disk_gb ?? 0),
|
||||
'cpu_limit_percent' => $resourceLimits['cpu_limit_percent'],
|
||||
'memory_limit_mb' => $resourceLimits['memory_limit_mb'],
|
||||
'process_limit' => $resourceLimits['process_limit'],
|
||||
'io_limit_mb' => $resourceLimits['io_limit_mb'],
|
||||
'inode_limit' => $resourceLimits['inode_limit'],
|
||||
'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE,
|
||||
'expires_at' => $this->expiresAtFromDuration($durationMonths),
|
||||
'resource_limits' => array_merge([
|
||||
'disk_gb' => (int) ($product->disk_gb ?? 0),
|
||||
'max_domains' => $product->max_domains,
|
||||
'max_databases' => $product->max_databases,
|
||||
], $resourceLimits),
|
||||
'metadata' => [
|
||||
'assigned_by_admin' => $input['assigned_by_admin'] ?? null,
|
||||
'assigned_at' => now()->toISOString(),
|
||||
'assigned_duration_months' => $durationMonths,
|
||||
'notes' => $input['notes'] ?? null,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($node) {
|
||||
ProvisionHostingAccountJob::dispatch($hostingAccount->id);
|
||||
|
||||
return [
|
||||
'account' => $this->serializeAccount($hostingAccount->fresh(['product'])),
|
||||
'provisioning' => true,
|
||||
'message' => "Hosting product '{$product->name}' assigned. Provisioning is in progress.",
|
||||
];
|
||||
}
|
||||
|
||||
$hostingAccount->update([
|
||||
'status' => HostingAccount::STATUS_ACTIVE,
|
||||
'provisioned_at' => now(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'account' => $this->serializeAccount($hostingAccount->fresh(['product'])),
|
||||
'provisioning' => false,
|
||||
'message' => "Hosting product '{$product->name}' assigned.",
|
||||
];
|
||||
}
|
||||
|
||||
public function updateDuration(HostingAccount $account, int $durationMonths, ?int $adminId = null): array
|
||||
{
|
||||
$metadata = (array) ($account->metadata ?? []);
|
||||
$metadata['assigned_duration_months'] = $durationMonths;
|
||||
$metadata['duration_updated_by_admin'] = $adminId;
|
||||
$metadata['duration_updated_at'] = now()->toISOString();
|
||||
|
||||
$account->update([
|
||||
'expires_at' => $this->expiresAtFromDuration($durationMonths),
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
return $this->serializeAccount($account->fresh(['product']));
|
||||
}
|
||||
|
||||
public function renew(HostingAccount $account, ?int $adminId = null): array
|
||||
{
|
||||
if (! $account->canBeRenewed()) {
|
||||
throw ValidationException::withMessages([
|
||||
'account' => 'This hosting account does not have a configured renewal duration.',
|
||||
]);
|
||||
}
|
||||
|
||||
$months = $account->assignedDurationMonths();
|
||||
$account->renew($months, [
|
||||
'renewed_by_admin' => $adminId,
|
||||
'renewed_at' => now()->toISOString(),
|
||||
]);
|
||||
|
||||
return $this->serializeAccount($account->fresh(['product']));
|
||||
}
|
||||
|
||||
public function unsuspend(HostingAccount $account, HostingResourcePolicyService $resourcePolicy): void
|
||||
{
|
||||
if (
|
||||
$account->status !== HostingAccount::STATUS_SUSPENDED
|
||||
&& $account->resource_status !== HostingAccount::RESOURCE_STATUS_SUSPENDED
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'account' => 'This hosting account is not suspended.',
|
||||
]);
|
||||
}
|
||||
|
||||
$resourcePolicy->unsuspendAccount($account, 'Manual unsuspension by admin.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function serializeAccount(HostingAccount $account): array
|
||||
{
|
||||
$product = $account->product;
|
||||
|
||||
return [
|
||||
'id' => $account->id,
|
||||
'username' => $account->username,
|
||||
'primary_domain' => $account->primary_domain,
|
||||
'type' => $account->type,
|
||||
'status' => $account->status,
|
||||
'resource_status' => $account->resource_status,
|
||||
'expires_at' => $account->expires_at?->toIso8601String(),
|
||||
'provisioned_at' => $account->provisioned_at?->toIso8601String(),
|
||||
'metadata' => (array) ($account->metadata ?? []),
|
||||
'product' => $product ? [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'type' => $product->type,
|
||||
'slug' => $product->slug,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listProducts(): array
|
||||
{
|
||||
return HostingProduct::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (HostingProduct $product) => [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'type' => $product->type,
|
||||
'slug' => $product->slug,
|
||||
'display_currency' => $product->display_currency,
|
||||
'display_price_monthly' => $product->display_price_monthly,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function generateUsername(User $user): string
|
||||
{
|
||||
$baseUsername = strtolower(str_replace([' ', '.', '_'], '', $user->name));
|
||||
$baseUsername = preg_replace('/[^a-z0-9]/', '', $baseUsername);
|
||||
|
||||
if (strlen($baseUsername) < 3) {
|
||||
$baseUsername = strtolower(substr($user->email, 0, str_contains($user->email, '@') ? strpos($user->email, '@') : strlen($user->email)));
|
||||
$baseUsername = preg_replace('/[^a-z0-9]/', '', $baseUsername);
|
||||
}
|
||||
|
||||
if (strlen($baseUsername) < 3) {
|
||||
$baseUsername = 'user'.$user->id;
|
||||
}
|
||||
|
||||
$username = substr($baseUsername, 0, 16);
|
||||
$counter = 1;
|
||||
$originalUsername = $username;
|
||||
|
||||
while (HostingAccount::query()->where('username', $username)->exists()) {
|
||||
$suffix = (string) $counter;
|
||||
$maxBaseLength = 16 - strlen($suffix);
|
||||
$username = substr($originalUsername, 0, $maxBaseLength).$suffix;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
return $username;
|
||||
}
|
||||
|
||||
private function expiresAtFromDuration(int $durationMonths): CarbonInterface
|
||||
{
|
||||
return now()->addMonthsNoOverflow($durationMonths);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user