Files
ladill-hosting/app/Services/Hosting/AdminHostingAssignmentService.php
T
isaaccladandCursor 2c9877a87c
Deploy Ladill Hosting / deploy (push) Successful in 1m29s
Add platform admin hosting API for Ladill account provisioning.
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>
2026-07-03 22:54:00 +00:00

244 lines
8.7 KiB
PHP

<?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);
}
}