Add bulk update gateway endpoint to vCenter API routes
- Introduced a new POST route 'bulk-update-gateway' for bulk updating gateways. - This route is protected by the 'module-vcenter.manager' permission middleware.
This commit is contained in:
@@ -40,7 +40,11 @@
|
||||
"Bash(grep -n -A 30 \"CREATE TABLE \\\\`products\\\\`\" /Users/baro/Desktop/Workspace/thegioiso/web/portal-infra/portal.sql)",
|
||||
"Bash(grep -n -A 30 \"CREATE TABLE \\\\`vmware\\\\`\" /Users/baro/Desktop/Workspace/thegioiso/web/portal-infra/portal.sql)",
|
||||
"Bash(pnpm tsc *)",
|
||||
"Bash(grep *)"
|
||||
"Bash(grep *)",
|
||||
"Bash(python3 *)",
|
||||
"Bash(pnpm exec *)",
|
||||
"Bash(echo \"exit: $?\")",
|
||||
"Bash(node -e \"console.log\\(require.resolve\\('ag-grid-react/package.json'\\)\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,11 @@ use Maatwebsite\Excel\Validators\ValidationException;
|
||||
|
||||
class IpPoolController extends Controller
|
||||
{
|
||||
/**
|
||||
* Descriptive/CSV-import fields — plain pass-through, unlike gateway/cidr/netmask/
|
||||
* status/service_id which have defaulting + module_ip_address mirror logic below.
|
||||
*/
|
||||
private const EDITABLE_FIELDS = [
|
||||
'gateway', 'netmask', 'service_id', 'status',
|
||||
'owner_type', 'ip_role', 'parent_ip', 'customer_name', 'service_name',
|
||||
'allocated_at', 'revoked_at', 'allocation_status',
|
||||
'contact_email', 'contact_cc', 'contact_phone',
|
||||
@@ -24,9 +27,25 @@ class IpPoolController extends Controller
|
||||
'last_email_type', 'last_email_at', 'updated_by', 'note',
|
||||
];
|
||||
|
||||
/**
|
||||
* contact_email/contact_cc may hold multiple addresses separated by comma/semicolon.
|
||||
*/
|
||||
private function multiEmailRule(): \Closure
|
||||
{
|
||||
return function (string $attribute, mixed $value, \Closure $fail) {
|
||||
foreach (preg_split('/[,;]+/', (string) $value) as $email) {
|
||||
$email = trim($email);
|
||||
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$fail("Email không hợp lệ: {$email}");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$perPage = min((int) $request->get('per_page', 15), 500);
|
||||
// 'all' lets the admin grid load the full dataset once and filter/sort client-side.
|
||||
$perPage = $request->boolean('all') ? 20000 : min((int) $request->get('per_page', 15), 500);
|
||||
$filter = $request->get('filter');
|
||||
$networkId = $request->get('network_id');
|
||||
|
||||
@@ -133,6 +152,10 @@ class IpPoolController extends Controller
|
||||
'netmask' => 'nullable|string|max:45',
|
||||
'status' => 'nullable|in:free,assigned,reserved',
|
||||
'service_id' => 'nullable|integer|exists:services,id',
|
||||
'contact_email' => ['nullable', 'string', $this->multiEmailRule()],
|
||||
'contact_cc' => ['nullable', 'string', $this->multiEmailRule()],
|
||||
'monthly_cost' => 'nullable|numeric',
|
||||
'selling_price' => 'nullable|numeric',
|
||||
]);
|
||||
|
||||
$networkId = (int) $request->module_network_id;
|
||||
@@ -174,6 +197,7 @@ class IpPoolController extends Controller
|
||||
$hostId = ModuleNetwork::find($networkId)?->module_host_id;
|
||||
$netmask = $request->netmask ?: '255.255.255.0';
|
||||
$cidr = $request->cidr ?? 24;
|
||||
$extra = $request->only(self::EDITABLE_FIELDS);
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
@@ -187,6 +211,7 @@ class IpPoolController extends Controller
|
||||
'netmask' => $netmask,
|
||||
'status' => $request->status ?: 'free',
|
||||
'service_id' => $request->service_id,
|
||||
...$extra,
|
||||
]);
|
||||
|
||||
// Mirror into module_ip_address so both tables stay in sync.
|
||||
@@ -229,6 +254,10 @@ class IpPoolController extends Controller
|
||||
'netmask' => 'nullable|string|max:45',
|
||||
'status' => 'nullable|in:free,assigned,reserved',
|
||||
'service_id' => 'nullable|integer|exists:services,id',
|
||||
'contact_email' => ['nullable', 'string', $this->multiEmailRule()],
|
||||
'contact_cc' => ['nullable', 'string', $this->multiEmailRule()],
|
||||
'monthly_cost' => 'nullable|numeric',
|
||||
'selling_price' => 'nullable|numeric',
|
||||
]);
|
||||
|
||||
if (!filter_var($request->ip_address, FILTER_VALIDATE_IP)) {
|
||||
@@ -256,6 +285,7 @@ class IpPoolController extends Controller
|
||||
try {
|
||||
$pool->update([
|
||||
...$request->only(['ip_address', 'module_network_id', 'gateway', 'cidr', 'service_id']),
|
||||
...$request->only(self::EDITABLE_FIELDS),
|
||||
'netmask' => $netmask,
|
||||
'status' => $request->status ?: $pool->status,
|
||||
]);
|
||||
@@ -312,6 +342,44 @@ class IpPoolController extends Controller
|
||||
return response()->json(['error' => false, 'message' => 'Đã xoá IP Pool thành công']);
|
||||
}
|
||||
|
||||
public function bulkUpdateGateway(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'ids' => 'required|array|min:1',
|
||||
'ids.*' => 'integer',
|
||||
'gateway' => 'required|string|max:45',
|
||||
]);
|
||||
|
||||
$pools = IpPool::whereIn('id', $request->ids)->get(['id', 'ip_address', 'module_network_id']);
|
||||
|
||||
if ($pools->isEmpty()) {
|
||||
return response()->json(['error' => true, 'message' => 'Không tìm thấy IP Pool nào'], 404);
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
IpPool::whereIn('id', $pools->pluck('id'))->update(['gateway' => $request->gateway]);
|
||||
|
||||
// Mirror into module_ip_address, grouped by network to keep this to a handful of queries.
|
||||
foreach ($pools->groupBy('module_network_id') as $networkId => $group) {
|
||||
ModuleIpAddress::whereIn('ip', $group->pluck('ip_address'))
|
||||
->where('module_network_id', $networkId)
|
||||
->update(['gateway' => $request->gateway]);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return response()->json(['error' => true, 'message' => $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => "Đã cập nhật gateway cho {$pools->count()} IP",
|
||||
'updated' => $pools->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function bulkDelete(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
@@ -354,6 +422,42 @@ class IpPoolController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function import(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:csv,txt|max:10240',
|
||||
]);
|
||||
|
||||
$countBefore = IpPool::withTrashed()->count();
|
||||
|
||||
try {
|
||||
Excel::import(new IpPoolImport(), $request->file('file'));
|
||||
} catch (ValidationException $e) {
|
||||
$errors = [];
|
||||
foreach ($e->failures() as $failure) {
|
||||
$errors[] = 'Dòng ' . $failure->row() . ': ' . implode(', ', $failure->errors());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => true,
|
||||
'message' => 'Import thất bại',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => true, 'message' => 'Import thất bại: ' . $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
$countAfter = IpPool::withTrashed()->count();
|
||||
|
||||
IpPool::resyncAllContacts();
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => 'Import thành công',
|
||||
'created' => max(0, $countAfter - $countBefore),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill/repair the module_ip_address mirror for every ip_pools row.
|
||||
* Existing mirror rows only get gateway/netmask refreshed — status/vm_name
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Imports;
|
||||
|
||||
use App\Models\IpPool;
|
||||
use App\Models\ModuleNetwork;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Maatwebsite\Excel\Concerns\ToModel;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||
@@ -13,17 +14,22 @@ use Maatwebsite\Excel\Concerns\WithValidation;
|
||||
|
||||
class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpserts, WithUpsertColumns
|
||||
{
|
||||
/** @var array<string, int> */
|
||||
/** @var array<string, ModuleNetwork> */
|
||||
private array $networkCache = [];
|
||||
|
||||
public function model(array $row)
|
||||
{
|
||||
$network = $this->resolveNetwork($row['ten_mang'] ?? null, $row['cidr'] ?? null);
|
||||
|
||||
return new IpPool([
|
||||
// uuid has no DB default and must be set on insert; excluded from upsertColumns() so it's never overwritten
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'ip_address' => $row['ip_address'],
|
||||
'module_network_id' => $this->resolveNetworkId($row['ten_mang'] ?? null, $row['cidr'] ?? null),
|
||||
'cidr' => $this->parsePrefixLength($row['cidr'] ?? null),
|
||||
'module_network_id' => $network?->id,
|
||||
// ip_pools.cidr is NOT NULL; fall back to the network's own prefix (or 24) when the row omits it
|
||||
'cidr' => $this->parsePrefixLength($row['cidr'] ?? null)
|
||||
?? $this->parsePrefixLength($network?->network)
|
||||
?? 24,
|
||||
'gateway' => $row['gateway'] ?? null,
|
||||
'netmask' => $row['subnet_mask'] ?? null,
|
||||
'owner_type' => $row['owner_type'] ?? null,
|
||||
@@ -31,8 +37,8 @@ class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpser
|
||||
'parent_ip' => $row['parent_ip'] ?? null,
|
||||
'customer_name' => $row['ten_khach_hang'] ?? null,
|
||||
'service_name' => $row['ten_dich_vu'] ?? null,
|
||||
'allocated_at' => $row['ngay_cap_phat'] ?: null,
|
||||
'revoked_at' => $row['ngay_thu_hoi'] ?: null,
|
||||
'allocated_at' => $this->parseDate($row['ngay_cap_phat'] ?? null),
|
||||
'revoked_at' => $this->parseDate($row['ngay_thu_hoi'] ?? null),
|
||||
'allocation_status' => $row['trang_thai'] ?? null,
|
||||
'contact_email' => $row['contact_email'] ?? null,
|
||||
'contact_cc' => $row['contact_cc'] ?? null,
|
||||
@@ -41,7 +47,7 @@ class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpser
|
||||
'selling_price' => $row['selling_price'] ?: null,
|
||||
'additional_info' => $row['thong_tin_bo_sung'] ?? null,
|
||||
'last_email_type' => $row['last_email_type'] ?? null,
|
||||
'last_email_at' => $row['last_email_at'] ?: null,
|
||||
'last_email_at' => $this->parseDate($row['last_email_at'] ?? null),
|
||||
'updated_by' => $row['updated_by'] ?? null,
|
||||
'note' => $row['ghi_chu'] ?? null,
|
||||
]);
|
||||
@@ -49,16 +55,49 @@ class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpser
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$multiEmail = function (string $attribute, mixed $value, \Closure $fail) {
|
||||
foreach (preg_split('/[,;]+/', (string) $value) as $email) {
|
||||
$email = trim($email);
|
||||
if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$fail("Email không hợp lệ: {$email}");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
'ten_mang' => 'required|string|max:255',
|
||||
'cidr' => 'nullable|string|max:45',
|
||||
'ip_address' => 'required|ip',
|
||||
'contact_email' => 'nullable|email',
|
||||
'contact_email' => ['nullable', 'string', $multiEmail],
|
||||
'contact_cc' => ['nullable', 'string', $multiEmail],
|
||||
'monthly_cost' => 'nullable|numeric',
|
||||
'selling_price' => 'nullable|numeric',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV dates come as "dd/mm/yyyy" or "dd/mm/yyyy H:i:s" (Vietnamese sheet export).
|
||||
*/
|
||||
private function parseDate(?string $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::createFromFormat('d/m/Y H:i:s', $value)->toDateTimeString();
|
||||
} catch (\Throwable) {
|
||||
// not a datetime, try date-only below
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::createFromFormat('d/m/Y', $value)->startOfDay()->toDateTimeString();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV gives CIDR as "/22"; the ip_pools.cidr column only stores the prefix length.
|
||||
*/
|
||||
@@ -83,7 +122,7 @@ class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpser
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveNetworkId(?string $name, ?string $cidr): ?int
|
||||
private function resolveNetwork(?string $name, ?string $cidr): ?ModuleNetwork
|
||||
{
|
||||
if (!$name) {
|
||||
return null;
|
||||
@@ -99,6 +138,6 @@ class IpPoolImport implements ToModel, WithHeadingRow, WithValidation, WithUpser
|
||||
?? ($cidr ? ModuleNetwork::where('network', $cidr)->first() : null)
|
||||
?? ModuleNetwork::create(['name' => $name, 'network' => $cidr]);
|
||||
|
||||
return $this->networkCache[$key] = $network->id;
|
||||
return $this->networkCache[$key] = $network;
|
||||
}
|
||||
}
|
||||
|
||||
+64
-7
@@ -13,16 +13,12 @@ class IpPool extends Model
|
||||
use HasFactory, SoftDeletes, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'uuid',
|
||||
'ip_address',
|
||||
'gateway',
|
||||
'cidr',
|
||||
'netmask',
|
||||
'module_network_id',
|
||||
'gateway',
|
||||
'cidr',
|
||||
'netmask',
|
||||
'status',
|
||||
'owner_type',
|
||||
'ip_role',
|
||||
@@ -32,6 +28,7 @@ class IpPool extends Model
|
||||
'allocated_at',
|
||||
'revoked_at',
|
||||
'allocation_status',
|
||||
'service_id',
|
||||
'contact_email',
|
||||
'contact_cc',
|
||||
'contact_phone',
|
||||
@@ -51,9 +48,6 @@ class IpPool extends Model
|
||||
'monthly_cost' => 'decimal:2',
|
||||
'selling_price' => 'decimal:2',
|
||||
];
|
||||
'status',
|
||||
'service_id',
|
||||
];
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
@@ -64,6 +58,14 @@ class IpPool extends Model
|
||||
$model->uuid = (string) Str::uuid();
|
||||
}
|
||||
});
|
||||
|
||||
// Keep ip_pool_contacts in sync whenever contact_email/contact_cc change via normal Eloquent saves.
|
||||
// Bulk import (WithUpserts) bypasses this — call resyncAllContacts() after Excel::import() instead.
|
||||
static::saved(function (self $model) {
|
||||
if ($model->wasChanged(['contact_email', 'contact_cc']) || $model->wasRecentlyCreated) {
|
||||
$model->syncContacts();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function network()
|
||||
@@ -80,4 +82,59 @@ class IpPool extends Model
|
||||
{
|
||||
return $this->hasMany(VirtualMachines::class, 'ip_pool_id');
|
||||
}
|
||||
|
||||
public function contacts()
|
||||
{
|
||||
return $this->hasMany(IpPoolContact::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* contact_email/contact_cc may hold multiple addresses separated by comma/semicolon
|
||||
* (e.g. imported from Google Sheet). Split into a clean array, dropping invalid/empty entries.
|
||||
*/
|
||||
public static function splitEmails(?string $value): array
|
||||
{
|
||||
if (!$value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect(preg_split('/[,;]+/', $value))
|
||||
->map(fn ($e) => trim($e))
|
||||
->filter(fn ($e) => $e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function syncContacts(): void
|
||||
{
|
||||
$rows = [];
|
||||
foreach (self::splitEmails($this->contact_email) as $email) {
|
||||
$rows[] = ['ip_pool_id' => $this->id, 'email' => $email, 'type' => 'to'];
|
||||
}
|
||||
foreach (self::splitEmails($this->contact_cc) as $email) {
|
||||
$rows[] = ['ip_pool_id' => $this->id, 'email' => $email, 'type' => 'cc'];
|
||||
}
|
||||
|
||||
$this->contacts()->delete();
|
||||
if ($rows) {
|
||||
IpPoolContact::insert($rows);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full resync for bulk paths (CSV import) that bypass the saved() hook above.
|
||||
*/
|
||||
public static function resyncAllContacts(): void
|
||||
{
|
||||
self::withTrashed()
|
||||
->where(function ($q) {
|
||||
$q->whereNotNull('contact_email')->orWhereNotNull('contact_cc');
|
||||
})
|
||||
->chunkById(500, function ($pools) {
|
||||
foreach ($pools as $pool) {
|
||||
$pool->syncContacts();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class IpPoolContact extends Model
|
||||
{
|
||||
protected $fillable = ['ip_pool_id', 'email', 'type'];
|
||||
|
||||
public function ipPool()
|
||||
{
|
||||
return $this->belongsTo(IpPool::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ip_pool_contacts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('ip_pool_id')->constrained('ip_pools')->cascadeOnDelete();
|
||||
$table->string('email');
|
||||
$table->enum('type', ['to', 'cc']);
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['ip_pool_id', 'email', 'type']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ip_pool_contacts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,582 @@
|
||||
# Hosting Management Module — Phase 1 (Provisioning) Design
|
||||
|
||||
Scope: **create Plesk Customer → create Hosting Subscription → assign Plan →
|
||||
persist the local record.** Nothing else. Database/SSL/Mail/Git
|
||||
deploy/Let's Encrypt/auto-upload are explicitly out of scope and are not
|
||||
stubbed — no placeholder methods for them are created in this pass.
|
||||
|
||||
This revises the earlier proposal in this file: `type` is now a `varchar`
|
||||
column on `dns_manager_servers` (not a capability pivot), per explicit
|
||||
instruction. Everything else below is re-verified against the actual
|
||||
source, not carried over by assumption.
|
||||
|
||||
## 1. Audit — what's actually in the codebase
|
||||
|
||||
### 1.1 Domain / DNS / vCenter modules
|
||||
|
||||
- **DNS**: table `dns_manager_servers` (model `App\Models\DnsServer`) — the
|
||||
live Plesk credential store: `id, name, ip_address, username, password,
|
||||
api_key, status, timestamps`. No `type`, `hostname`, `port`, `protocol`
|
||||
columns exist today. `dns_manager_domain` (model `DnsDomain`) has
|
||||
`dns_manager_server_id` FK and `plesk_id`. `dns_manager_ns` (model
|
||||
`DnsManagerNS`) has `plesk_id` too.
|
||||
Repository: `App\Repositories\DnsManager\Server\DnsServerRepositoryInterface`
|
||||
→ bound in `RepositoryProvider` — **actually check**: this specific
|
||||
interface/binding was *not* found in `RepositoryProvider.php`'s bind list
|
||||
(only `Module\DnsServer\DnsServerRepositoryInterface`, a different,
|
||||
legacy class tied to table `module_dns_server`, was found bound). This
|
||||
means `ServerService` (below) currently resolves
|
||||
`DnsManager\Server\DnsServerRepositoryInterface` from the container
|
||||
without an explicit binding visible in the audited file — either it's
|
||||
bound elsewhere (a second provider) or Laravel's implicit binding/
|
||||
interface-to-concrete auto-resolution isn't in play and this is already
|
||||
fragile. **Do not copy this uncertainty into the new module** — bind the
|
||||
new interface explicitly (§6).
|
||||
- Plesk HTTP calls today live in `App\Services\ServerService`, talking to
|
||||
`:8443/api/v2/{server,domains,dns/records}` with
|
||||
`Http::withoutVerifying()->withBasicAuth($username, Crypt::decryptString($password))`.
|
||||
It already implements `createDomain()`, `updateDomain()`, `removeDomain()`,
|
||||
`getDomainInfo()`, `listRecords()`, `checkConnection()`. `createDomain()`
|
||||
already POSTs `hosting_settings.ftp_login/ftp_password` + `plan` — i.e.
|
||||
it's already creating a **hosting-capable** Plesk domain object, not a
|
||||
bare DNS zone. This is the piece to extract and reuse (§7), not
|
||||
reimplement.
|
||||
- **vCenter**: `App\Http\Services\vCenter\Client` — same shape (`Http`
|
||||
facade, Basic Auth, returns raw arrays/bools), different domain. Confirms
|
||||
"thin service class over Http facade, no HTTP abstraction layer" is the
|
||||
house style, not a DNS-module one-off.
|
||||
- **Legacy Hosting** (`module_hosting`, `module_hosting_server`,
|
||||
`App\Http\Services\Hosting`, `App\Jobs\Hosting\CreateHostingJobs`): a
|
||||
pre-`services` era, WHMCS-shaped flow. Different crypto (`SignRSA`, not
|
||||
`Crypt::`), different credential table, different (non-Plesk-v2-REST)
|
||||
API surface (`CheckUserClient`, `CreateNewClient`, `CreateNewDomain`,
|
||||
`CreateExpirationForDomain`), stores the account password directly on
|
||||
`module_hosting.password`. **Not reused** — instruction says no legacy
|
||||
tables and no duplicated data; this codebase's own `services`/`products`
|
||||
layer already superseded it for VPS (`module_vmware` + `services.vm_id`)
|
||||
and Hosting should follow that same replacement, not the old one.
|
||||
- **`control_panels` / `product_control_panel`**: exist, seeded (Plesk,
|
||||
DirectAdmin, cPanel, ...), unused by any controller/service found. Out of
|
||||
scope to wire up in Phase 1 (no product-selection UI requested), but
|
||||
worth knowing they exist so Phase 1 doesn't hardcode "Plesk" somewhere
|
||||
that later blocks reading the panel off the product.
|
||||
|
||||
### 1.2 Repository Pattern
|
||||
|
||||
Confirmed convention, three files per resource:
|
||||
|
||||
```
|
||||
XRepositoryInterface extends BaseRepositoryInterface
|
||||
XRepository extends BaseRepository (getModel(), query logic)
|
||||
XCachedRepository extends BaseCachedRepository, implements XRepositoryInterface
|
||||
(wraps XRepository, Cache::remember on read methods)
|
||||
```
|
||||
|
||||
Bound in `app/Providers/RepositoryProvider.php`:
|
||||
`$this->app->singleton(XRepositoryInterface::class, XCachedRepository::class);`
|
||||
|
||||
This is the pattern used for `Module\DnsServer`, `Module\HostingServer`,
|
||||
`Module\Hosting` (legacy), `Domain`, etc. — followed as-is for the new
|
||||
module, no changes to the pattern itself.
|
||||
|
||||
### 1.3 Service Pattern
|
||||
|
||||
Plain `App\Services\*` or `App\Http\Services\{Domain}\*` classes, no
|
||||
interfaces, no DI container binding beyond constructor injection of
|
||||
repositories. Return shape for external-API-calling services observed
|
||||
consistently: `['error' => bool, 'data' => ..., 'message' => ...]` (see
|
||||
`ServerService` — every public method returns this shape or `array|false`).
|
||||
Followed as-is.
|
||||
|
||||
### 1.4 Naming convention
|
||||
|
||||
- Tables: snake_case, no prefix for "core" entities (`customers`,
|
||||
`services`, `products`), `module_` prefix for provisioned-resource
|
||||
tables descended from the legacy admin panel (`module_vmware`,
|
||||
`module_dns_server`, `module_hosting`). No `tbl_` prefix anywhere in the
|
||||
codebase.
|
||||
- Migrations: `unsignedBigInteger('x_id')->index()` + a separate
|
||||
`$table->foreign('x_id')->references('id')->on('x')->...` call is the
|
||||
dominant style (46 migrations) over the `foreignId()` shorthand (18
|
||||
migrations, mostly older). Followed as the majority style for
|
||||
consistency with every table this module touches
|
||||
(`dns_manager_servers`, `services`, `customers`).
|
||||
- Migration filenames: `YYYY_MM_DD_HHMMSS_verb_noun.php`, one
|
||||
`Schema::create`/`Schema::table` block per concern; multi-step column
|
||||
changes get their own migration file rather than being folded into the
|
||||
original create (e.g. `add_domain_fields_to_services.php`,
|
||||
`alter_services_vm_id_to_fk.php`).
|
||||
|
||||
### 1.5 Permission convention
|
||||
|
||||
Seeded in `database/seeders/RolesAndPermissions.php`, pattern
|
||||
`{module}.{view|create|edit|destroy|manager}`, guard `admin`. Routes apply
|
||||
via `->middleware('perm:{module}.{action}')`.
|
||||
|
||||
**A `module-hosting.{view,create,edit,destroy,manager}` permission set
|
||||
already exists** in the seeder (tied to the legacy `module_hosting`
|
||||
admin-blade screens, no route match found in `routes/api.php` — i.e.
|
||||
currently orphaned or admin-blade-only). Reusing it for the new API would
|
||||
silently couple this module's authorization to whatever the legacy screens
|
||||
still do with it. New module gets its own permission group instead:
|
||||
`hosting.{view,create,edit,destroy}` — matches the flat `domain.*`/
|
||||
`customer.*` style (no module- prefix) used for the *modern*,
|
||||
`services`-era modules, as opposed to the `module-*` prefix used for the
|
||||
older admin-panel-generation features (`module-vcenter.*`,
|
||||
`module-dns.*`). This keeps "which permission style = which generation of
|
||||
code" legible, which is already implicitly true in the seeder today.
|
||||
|
||||
## 2. `dns_manager_servers.type`
|
||||
|
||||
Decision: **`varchar`, not a native SQL `ENUM`.**
|
||||
|
||||
Reasons, specific to this codebase:
|
||||
- Zero existing migrations use MySQL `->enum(...)`. `dns_manager_servers.status`
|
||||
itself is a `string` column with values validated by convention/PHP,
|
||||
not a DB enum. Matching that means `type` should be `string` too — one
|
||||
column-kind convention for the whole table, not a mix.
|
||||
- `App\Enums\Domain\DomainProvisioningStatus` shows the actual "enum"
|
||||
convention in this codebase: a PHP backed enum class used for validation
|
||||
and `label()`/transition logic, with the DB column being a plain
|
||||
`string`. Adding `both` as a third case later, or a `mail`/`cdn` value in
|
||||
some future phase, is then a one-line enum case, not a migration to
|
||||
widen a native `ENUM` definition (which MySQL handles as a full table
|
||||
metadata rewrite).
|
||||
- Pivot/capability table and JSON were explicitly excluded by the request;
|
||||
they're also not needed here — `domain | hosting | both` is a small,
|
||||
stable, mutually-exclusive-enough set for a single column, unlike the
|
||||
earlier revision's "server can have an open-ended combination" framing.
|
||||
|
||||
```php
|
||||
enum ServerType: string
|
||||
{
|
||||
case DOMAIN = 'domain';
|
||||
case HOSTING = 'hosting';
|
||||
case BOTH = 'both';
|
||||
}
|
||||
```
|
||||
|
||||
Query helper needed because of `both`: any lookup for "servers usable for
|
||||
hosting" must match `type IN ('hosting', 'both')`, not `type = 'hosting'`.
|
||||
This is the one real cost of `varchar`+`both` over a pivot — every query
|
||||
site needs to know to expand `both`. Put that in one place:
|
||||
`DnsServerRepositoryInterface::getUsableFor(ServerType $type)` on the
|
||||
repository, so `PleskHostingService` and any future admin filter never
|
||||
hand-roll the `IN (...)` themselves.
|
||||
|
||||
```php
|
||||
// migration: add_type_to_dns_manager_servers.php
|
||||
Schema::table('dns_manager_servers', function (Blueprint $table) {
|
||||
$table->string('type', 20)->default('domain')->after('status');
|
||||
});
|
||||
// backfill: every existing row already serves DNS today
|
||||
DB::table('dns_manager_servers')->update(['type' => 'domain']);
|
||||
```
|
||||
|
||||
## 3. Hosting module — table name
|
||||
|
||||
`module_hosting_accounts`. Follows the `module_` + resource-noun pattern
|
||||
already used for `module_vmware` (the closest analog: a provisioned
|
||||
resource hanging off `services`, one credential-holding parent per row).
|
||||
Does not collide with legacy `module_hosting`/`module_hosting_server`
|
||||
(different tables, different meaning, left untouched — not migrated into,
|
||||
not read from).
|
||||
|
||||
## 4. Migration
|
||||
|
||||
```php
|
||||
// database/migrations/2026_08_06_130000_add_type_to_dns_manager_servers_table.php
|
||||
Schema::table('dns_manager_servers', function (Blueprint $table) {
|
||||
$table->string('type', 20)->default('domain')->after('status');
|
||||
});
|
||||
```
|
||||
|
||||
```php
|
||||
// database/migrations/2026_08_06_130001_create_module_hosting_accounts_table.php
|
||||
Schema::create('module_hosting_accounts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->unsignedBigInteger('customer_id')->index();
|
||||
$table->unsignedBigInteger('service_id')->nullable()->index();
|
||||
$table->unsignedBigInteger('dns_manager_server_id')->index();
|
||||
$table->unsignedBigInteger('dns_manager_domain_id')->nullable()->index();
|
||||
|
||||
$table->unsignedInteger('plesk_client_id')->nullable();
|
||||
$table->unsignedInteger('plesk_subscription_id')->nullable()->index();
|
||||
$table->string('plesk_guid', 64)->nullable();
|
||||
|
||||
$table->string('hosting_plan', 100)->nullable();
|
||||
$table->string('ftp_username', 100)->nullable();
|
||||
$table->string('system_username', 100)->nullable();
|
||||
$table->string('document_root', 255)->nullable();
|
||||
|
||||
// pending -> active -> suspended -> terminated (mirrors services.status vocabulary)
|
||||
$table->string('status', 20)->default('pending')->index();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('customer_id')->references('id')->on('customers')->onDelete('cascade');
|
||||
$table->foreign('service_id')->references('id')->on('services')->onDelete('set null');
|
||||
$table->foreign('dns_manager_server_id')->references('id')->on('dns_manager_servers')->onDelete('restrict');
|
||||
$table->foreign('dns_manager_domain_id')->references('id')->on('dns_manager_domain')->onDelete('set null');
|
||||
});
|
||||
```
|
||||
|
||||
No password / FTP-password / Basic-Auth column — confirmed not needed:
|
||||
`dns_manager_servers` already holds what's required to manage the box: the
|
||||
FTP account Plesk creates is reachable/resettable through the same
|
||||
Basic-Auth credential, so nothing hosting-account-specific needs storing
|
||||
locally. (`module_vmware.plain_password` is a known wart elsewhere in this
|
||||
codebase — not repeated here.)
|
||||
|
||||
`plesk_guid` is `string(64)`, not `uuid()`: Plesk's GUID format isn't
|
||||
guaranteed to be a canonical RFC-4122 UUID the DB driver's `uuid` column
|
||||
type would validate against; storing it as an opaque string avoids a
|
||||
runtime insert failure on a format mismatch.
|
||||
|
||||
`service_id` is nullable: a hosting account can be provisioned by an admin
|
||||
ahead of/independent from an order line in Phase 1 (no order/service flow
|
||||
is being built here), matching how `services.vm_id` is nullable during
|
||||
the VM's own pending window.
|
||||
|
||||
## 5. Model
|
||||
|
||||
```php
|
||||
// app/Models/ModuleHostingAccount.php
|
||||
namespace App\Models;
|
||||
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ModuleHostingAccount extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $table = 'module_hosting_accounts';
|
||||
|
||||
protected $fillable = [
|
||||
'customer_id',
|
||||
'service_id',
|
||||
'dns_manager_server_id',
|
||||
'dns_manager_domain_id',
|
||||
'plesk_client_id',
|
||||
'plesk_subscription_id',
|
||||
'plesk_guid',
|
||||
'hosting_plan',
|
||||
'ftp_username',
|
||||
'system_username',
|
||||
'document_root',
|
||||
'status',
|
||||
];
|
||||
|
||||
public function customer()
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function service()
|
||||
{
|
||||
return $this->belongsTo(Service::class);
|
||||
}
|
||||
|
||||
public function server()
|
||||
{
|
||||
return $this->belongsTo(DnsServer::class, 'dns_manager_server_id');
|
||||
}
|
||||
|
||||
public function domain()
|
||||
{
|
||||
return $this->belongsTo(DnsDomain::class, 'dns_manager_domain_id');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`DnsServer` gets one addition — a typed accessor for `type`, matching how
|
||||
`DnsDomain`/`DnsServer` already expose small helper methods
|
||||
(`getStatus()`):
|
||||
|
||||
```php
|
||||
// App\Models\DnsServer — add
|
||||
public function servesHosting(): bool
|
||||
{
|
||||
return in_array($this->attributes['type'] ?? 'domain', ['hosting', 'both'], true);
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Relationships
|
||||
|
||||
```
|
||||
customers 1──* module_hosting_accounts
|
||||
services 1──0..1 module_hosting_accounts (service_id, nullable — same shape as services.vm_id → module_vmware)
|
||||
dns_manager_servers 1──* module_hosting_accounts (type must include 'hosting' or 'both')
|
||||
dns_manager_domain 1──0..1 module_hosting_accounts (dns_manager_domain_id, nullable)
|
||||
```
|
||||
|
||||
```
|
||||
Customer
|
||||
└─ Hosting Account (module_hosting_accounts)
|
||||
├─ DNS Manager Server (dns_manager_servers, type ∈ {hosting, both})
|
||||
└─ DNS Domain (dns_manager_domain, optional)
|
||||
```
|
||||
|
||||
**Domain Module link — yes, loosely, and only via `dns_manager_domain`,
|
||||
not `App\Models\Domain`.** Reasoning:
|
||||
|
||||
- `App\Models\Domain` (registrar-side TLD ownership, the "Domain
|
||||
Management" module in the request) and `App\Models\DnsDomain`
|
||||
(`dns_manager_domain`, a DNS zone actually hosted on one of our Plesk
|
||||
boxes) are different entities in the current schema — a customer can own
|
||||
a registrar domain that has never been pointed at our DNS/Plesk
|
||||
infrastructure, and conversely a DNS zone here doesn't strictly need to
|
||||
correspond to a domain we sold. Nothing in the audited code links them
|
||||
1:1 today.
|
||||
- Hosting only needs "which DNS zone/vhost name will Plesk serve this
|
||||
subscription under," which is exactly what `dns_manager_domain_id`
|
||||
captures. Forcing a link to `Domain` (registrar) would either require
|
||||
inventing a `domains.dns_manager_domain_id` bridge that doesn't exist
|
||||
(out of scope, a Domain-module change, not a Hosting-module one) or
|
||||
silently assume every hosting customer's domain is registrar-managed by
|
||||
us, which isn't guaranteed.
|
||||
- Keeping `dns_manager_domain_id` nullable also covers the CLI/admin flow
|
||||
of provisioning hosting before any DNS zone exists for it — Plesk's
|
||||
subscription can be created against a domain name string even if no
|
||||
local `dns_manager_domain` row is made yet in Phase 1.
|
||||
|
||||
## 7. Repository
|
||||
|
||||
```
|
||||
app/Repositories/Module/HostingAccount/
|
||||
HostingAccountRepositoryInterface.php
|
||||
HostingAccountRepository.php
|
||||
HostingAccountCachedRepository.php
|
||||
```
|
||||
|
||||
```php
|
||||
interface HostingAccountRepositoryInterface extends BaseRepositoryInterface
|
||||
{
|
||||
public function filter(
|
||||
array $data = [],
|
||||
array $relationships = [],
|
||||
array $withCount = [],
|
||||
array $select = ['*'],
|
||||
bool $returnQuery = false
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`HostingAccountRepository::getModel()` → `ModuleHostingAccount::class`,
|
||||
`filter()` implemented the same way as `HostingRepository::filter()`
|
||||
(status/relations/pagination handling already shown in that file) — copy
|
||||
that shape, don't invent a new query-building convention.
|
||||
|
||||
`HostingAccountCachedRepository` wraps it exactly like
|
||||
`DnsServerCachedRepository` does (`Cache::remember` on `filter`, pass-
|
||||
through when `returnQuery`/`paginate` is set).
|
||||
|
||||
Also add, on `App\Repositories\DnsManager\Server\DnsServerRepositoryInterface`
|
||||
(the interface `ServerService` already depends on):
|
||||
|
||||
```php
|
||||
public function getUsableFor(ServerType $type): Collection;
|
||||
// WHERE type = 'both' OR type = :type
|
||||
```
|
||||
|
||||
Bind both interfaces explicitly in `RepositoryProvider`:
|
||||
|
||||
```php
|
||||
$this->app->singleton(HostingAccountRepositoryInterface::class, HostingAccountCachedRepository::class);
|
||||
```
|
||||
|
||||
(And, while touching this file: confirm/fix the `DnsManager\Server`
|
||||
binding gap noted in §1.1 — the new Hosting service depends on the same
|
||||
interface `ServerService` uses, so if that resolution is currently
|
||||
implicit it needs to keep working for both consumers, not just happen to
|
||||
work for the one that existed first.)
|
||||
|
||||
## 8. Service — Plesk integration (Phase 1 only)
|
||||
|
||||
```
|
||||
app/Services/Plesk/
|
||||
PleskService.php // shared low-level HTTP: resolves a DnsServer → Http::withBasicAuth(...), used by all the others
|
||||
PleskClientService.php // find-or-create Plesk customer
|
||||
PleskDomainService.php // create/update/remove Plesk domain object — EXTRACTED from ServerService, not rewritten
|
||||
PleskHostingService.php // orchestrates: customer → subscription → plan → persist
|
||||
```
|
||||
|
||||
`PleskService` is the one new piece of shared plumbing, and it's a direct
|
||||
copy of what `ServerService` already does per-call (`Http::withoutVerifying()
|
||||
->withBasicAuth($server->username, Crypt::decryptString($server->password))
|
||||
->timeout(...)`), pulled out so `ServerService` (DNS) and the new
|
||||
`PleskHostingService` both call the same code instead of two Basic-Auth
|
||||
blocks drifting apart:
|
||||
|
||||
```php
|
||||
class PleskService
|
||||
{
|
||||
public function client(DnsServer $server): PendingRequest
|
||||
{
|
||||
return Http::withoutVerifying()
|
||||
->withBasicAuth($server->username, Crypt::decryptString($server->password))
|
||||
->timeout(30);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`PleskDomainService::createDomain()` is `ServerService::createDomain()`
|
||||
moved verbatim (same `hosting_settings`/`plan` payload shape, same
|
||||
`['error'=>bool,'data'=>...]` return) — `ServerService` then delegates to
|
||||
it instead of duplicating the HTTP call, so DNS-module behavior doesn't
|
||||
change.
|
||||
|
||||
`PleskClientService` — new for Phase 1, Plesk has no "create domain
|
||||
without a client" path that fits multi-tenant billing, so this is
|
||||
required, not optional:
|
||||
|
||||
```php
|
||||
class PleskClientService
|
||||
{
|
||||
public function __construct(private PleskService $plesk) {}
|
||||
|
||||
public function findOrCreateCustomer(DnsServer $server, Customer $customer): array
|
||||
{
|
||||
// GET /clients?login=... ; if 404, POST /clients
|
||||
// returns ['error' => bool, 'data' => ['id' => ..., 'guid' => ...]]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`PleskHostingService` — the Phase 1 entry point:
|
||||
|
||||
```php
|
||||
class PleskHostingService
|
||||
{
|
||||
public function __construct(
|
||||
private PleskClientService $clients,
|
||||
private PleskDomainService $domains,
|
||||
private HostingAccountRepositoryInterface $accounts,
|
||||
) {}
|
||||
|
||||
public function provision(array $data): array
|
||||
{
|
||||
// $data: customer_id, dns_manager_server_id, dns_manager_domain_id?,
|
||||
// domain_name, hosting_plan
|
||||
|
||||
$server = ...; // DnsServer::findOrFail, must servesHosting()
|
||||
|
||||
$client = $this->clients->findOrCreateCustomer($server, $customer);
|
||||
if ($client['error']) return $client;
|
||||
|
||||
$subscription = $this->domains->createDomain($server, [
|
||||
'domain_name' => $data['domain_name'],
|
||||
'plesk_client_id' => $client['data']['id'],
|
||||
'plan' => $data['hosting_plan'],
|
||||
]);
|
||||
if ($subscription['error']) return $subscription;
|
||||
|
||||
$account = $this->accounts->create([
|
||||
'customer_id' => $data['customer_id'],
|
||||
'service_id' => $data['service_id'] ?? null,
|
||||
'dns_manager_server_id' => $server->id,
|
||||
'dns_manager_domain_id' => $data['dns_manager_domain_id'] ?? null,
|
||||
'plesk_client_id' => $client['data']['id'],
|
||||
'plesk_subscription_id' => $subscription['data']['id'],
|
||||
'plesk_guid' => $subscription['data']['guid'],
|
||||
'hosting_plan' => $data['hosting_plan'],
|
||||
'ftp_username' => $subscription['data']['hosting_settings']['ftp_login'] ?? null,
|
||||
'system_username' => $subscription['data']['hosting_settings']['ftp_login'] ?? null,
|
||||
'document_root' => $subscription['data']['www_root'] ?? null,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
return ['error' => false, 'data' => $account];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No `suspend()`/`unsuspend()`/`resetFtpPassword()` bodies are written yet —
|
||||
the API routes for them exist (§9, per explicit request) but their
|
||||
controller actions return `501`/"not implemented in this phase" until
|
||||
those methods are built. Writing stub method bodies now that call nothing
|
||||
would be scaffolding for a phase that hasn't been scoped — the route
|
||||
existing is enough to reserve the contract.
|
||||
|
||||
## 9. Controller & API
|
||||
|
||||
```
|
||||
app/Http/Requests/ModuleHosting/CreateHostingAccountRequest.php
|
||||
app/Http/Requests/ModuleHosting/UpdateHostingAccountRequest.php
|
||||
app/Http/Resources/Api/Admin/HostingAccountResource.php
|
||||
app/Http/Controllers/Api/Admin/Hosting/HostingAccountController.php
|
||||
```
|
||||
|
||||
```php
|
||||
// routes/api.php, alongside the existing DNS Manager group
|
||||
Route::prefix('hosting-accounts')
|
||||
->controller(HostingAccountController::class)
|
||||
->group(function () {
|
||||
Route::get('/', 'index')->middleware('perm:hosting.view');
|
||||
Route::get('{id}', 'show')->middleware('perm:hosting.view');
|
||||
Route::post('/', 'store')->middleware('perm:hosting.create');
|
||||
Route::put('{id}', 'update')->middleware('perm:hosting.edit');
|
||||
Route::delete('{id}', 'destroy')->middleware('perm:hosting.destroy');
|
||||
|
||||
Route::post('{id}/suspend', 'suspend')->middleware('perm:hosting.edit');
|
||||
Route::post('{id}/unsuspend', 'unsuspend')->middleware('perm:hosting.edit');
|
||||
Route::post('{id}/reset-ftp-password', 'resetFtpPassword')->middleware('perm:hosting.edit');
|
||||
});
|
||||
```
|
||||
|
||||
`store()` calls `PleskHostingService::provision()` and returns the
|
||||
`HostingAccountResource`. `suspend`/`unsuspend`/`reset-ftp-password`
|
||||
controller methods exist per the request's API list but return
|
||||
`response()->json(['message' => 'Not implemented in this phase'], 501)` —
|
||||
routed and permission-gated now, implemented when their phase lands.
|
||||
|
||||
Add to `RolesAndPermissions.php` seeder:
|
||||
```php
|
||||
'hosting.view', 'hosting.create', 'hosting.edit', 'hosting.destroy',
|
||||
```
|
||||
|
||||
## 10. Reusable existing code
|
||||
|
||||
- `App\Services\ServerService::createDomain/updateDomain/removeDomain` →
|
||||
extracted into `PleskDomainService`, reused by both modules (§8).
|
||||
- Basic-Auth-over-`Http`-facade pattern → copied into `PleskService`
|
||||
exactly as done in `ServerService`/`vCenter\Client`, no new HTTP layer.
|
||||
- `Crypt::decryptString($server->password)` → same decryption call reused
|
||||
as-is; no new encryption scheme introduced (explicitly not `SignRSA`,
|
||||
which is the legacy module's scheme).
|
||||
- `BaseRepository`/`BaseCachedRepository`/`BaseRepositoryInterface` → used
|
||||
unchanged.
|
||||
- `App\Traits\LogsActivity` → reused on `ModuleHostingAccount`, same as
|
||||
`DnsServer`/`DnsDomain`/`ModuleHosting`.
|
||||
- Migration style, permission-seeding call site, route-group style — all
|
||||
copied from the DNS Manager section of `routes/api.php` and
|
||||
`RolesAndPermissions.php`, not reinvented.
|
||||
- `HostingRepository::filter()` (legacy, `app/Repositories/Module/Hosting/HostingRepository.php`)
|
||||
→ its filter/relation/status query shape is copied as the template for
|
||||
the new repository's `filter()`, even though the legacy table itself is
|
||||
not reused.
|
||||
|
||||
## 11. Trade-offs
|
||||
|
||||
| Decision | Trade-off accepted |
|
||||
|---|---|
|
||||
| `type` as `varchar` + `both` value | One extra `IN`-expansion needed at every "find a server for X" query site, centralized in `getUsableFor()`. In exchange: zero new tables, matches the table's existing `status` column style, and a future 4th type is a one-line enum case instead of an `ALTER TABLE ... MODIFY ENUM`. |
|
||||
| No pivot/capability table | Can't express fine-grained per-capability metadata (e.g. "default server for hosting" flag) — not needed by anything requested; if it's needed later it's an additive migration, not a rewrite of this one. |
|
||||
| `service_id` nullable, not required | Lets Phase 1 provision hosting without a full order/service pipeline wired up yet; costs a nullable check everywhere the relation is read. |
|
||||
| `dns_manager_domain_id` nullable, no FK to `App\Models\Domain` | Keeps Hosting decoupled from Domain-module assumptions that don't hold today (registrar domain ≠ DNS-managed zone); costs one more nullable relation to explain in onboarding docs. |
|
||||
| No password/Basic-Auth column on `module_hosting_accounts` | Every Plesk call for this account has to resolve back through `dns_manager_server_id` first — no local shortcut. In exchange: one credential source of truth, matches the explicit "don't store it twice" instruction, and avoids repeating `module_vmware.plain_password`. |
|
||||
| Extracting `PleskDomainService` out of `ServerService` rather than writing a second Plesk client | Touches an existing, working file (small risk) instead of a net-new, isolated one (zero risk to DNS module). In exchange: avoids the exact duplication (`module_hosting_server` vs `dns_manager_servers`, two unrelated Plesk-ish clients) that already happened once in this codebase. |
|
||||
| New `hosting.*` permission group instead of reusing seeded `module-hosting.*` | One more permission group to seed. In exchange: doesn't couple a new, modern API to whatever the legacy admin-blade screens currently do with `module-hosting.*`, and keeps the "flat name = modern services-era module" convention legible. |
|
||||
| No stub bodies for suspend/unsuspend/reset-ftp-password | Routes exist per the request's API list but 501 until built — avoids writing speculative Plesk-call code for a phase not yet scoped, per explicit instruction to design Phase 1 only. |
|
||||
|
||||
## 12. Explicitly not designed here
|
||||
|
||||
Per instruction: Database, SSL, Mailbox, dedicated FTP account creation
|
||||
beyond Plesk's default system user, Git deploy, source auto-upload,
|
||||
Let's Encrypt. No schema columns, service methods, or routes for these are
|
||||
included above, including no nullable "reserved for later" columns on
|
||||
`module_hosting_accounts` — adding those now would be exactly the
|
||||
speculative scaffolding this phase is meant to avoid.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -604,6 +604,7 @@ Route::prefix('v1')
|
||||
Route::post('sync', 'sync')->middleware('perm:module-vcenter.manager');
|
||||
Route::post('import', 'import')->middleware('perm:module-vcenter.manager');
|
||||
Route::delete('bulk', 'bulkDelete')->middleware('perm:module-vcenter.manager');
|
||||
Route::post('bulk-update-gateway', 'bulkUpdateGateway')->middleware('perm:module-vcenter.manager');
|
||||
Route::get('{id}', 'show')->middleware('perm:module-vcenter.view');
|
||||
Route::put('{id}', 'update')->middleware('perm:module-vcenter.manager');
|
||||
Route::delete('{id}', 'destroy')->middleware('perm:module-vcenter.manager');
|
||||
|
||||
Reference in New Issue
Block a user