feat: nhắc gia hạn QR theo dịch vụ, failover server hosting, CCCD admin, log cấp phát dịch vụ
- Mail campaign: mẫu nhắc gia hạn thanh toán QR, chọn dịch vụ + chu kỳ từng dòng (renewal_services) - Hosting: nhiều server Plesk cho một gói (service_plan_servers), chuyển server khi server chết - Admin xem/sửa giấy tờ định danh khách, tuỳ chọn gia hạn cho khách, nhật ký cấp phát dịch vụ - Đánh dấu mẫu email dịch vụ là non-marketing Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
047c23beff
commit
55a834a2f8
@@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule;
|
||||
use App\Support\BillingCycleCatalog;
|
||||
use App\Support\RenewalPeriodGuard;
|
||||
use App\Support\ServiceProvisionLog;
|
||||
|
||||
trait ManagesCustomerServices
|
||||
{
|
||||
@@ -134,6 +135,19 @@ trait ManagesCustomerServices
|
||||
return response()->json(['error' => false, 'data' => $renewals]);
|
||||
}
|
||||
|
||||
public function serviceProvisioningLogs(string $uuid, int $serviceId): JsonResponse
|
||||
{
|
||||
$service = Service::where('customer_id', $this->resolveCustomer($uuid)->id)->findOrFail($serviceId);
|
||||
|
||||
$logs = ActivityLog::where('subject_type', Service::class)
|
||||
->where('subject_id', $service->id)
|
||||
->whereIn('event', ServiceProvisionLog::EVENTS)
|
||||
->orderBy('id')
|
||||
->get(['id', 'event', 'description', 'causer_type', 'causer_name', 'new_values', 'created_at']);
|
||||
|
||||
return response()->json(['error' => false, 'data' => $logs]);
|
||||
}
|
||||
|
||||
public function renewService(string $uuid, int $serviceId, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\Customer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Customer;
|
||||
use App\Support\CustomerIdentity;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class CustomerIdentityController extends Controller
|
||||
{
|
||||
public function show(string $uuid): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => CustomerIdentity::payload(Customer::where('uuid', $uuid)->firstOrFail()),
|
||||
]);
|
||||
}
|
||||
|
||||
public function image(Customer $customer, string $side)
|
||||
{
|
||||
return CustomerIdentity::response($customer, $side);
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,7 @@ class EmailTemplateController extends Controller
|
||||
'message' => 'nullable|string',
|
||||
'builder_data' => 'nullable|array',
|
||||
'status' => 'boolean',
|
||||
'marketing' => 'nullable|boolean',
|
||||
'mail_config' => 'nullable|array',
|
||||
'mail_config.from_email' => 'nullable|email',
|
||||
'mail_config.from_name' => 'nullable|string|max:255',
|
||||
@@ -151,7 +152,10 @@ class EmailTemplateController extends Controller
|
||||
'message' => $data['message'] ?? null,
|
||||
'builder_data' => $data['builder_data'] ?? null,
|
||||
'status' => $data['status'] ?? 1,
|
||||
'metadata' => isset($data['mail_config']) ? ['mail' => $data['mail_config']] : null,
|
||||
'metadata' => array_filter([
|
||||
'mail' => $data['mail_config'] ?? null,
|
||||
'marketing' => isset($data['marketing']) ? (bool) $data['marketing'] : null,
|
||||
], fn ($value) => $value !== null) ?: null,
|
||||
]);
|
||||
$this->syncVariables($template, $data['variables'] ?? []);
|
||||
$guard->assertCanSave($template);
|
||||
@@ -177,6 +181,7 @@ class EmailTemplateController extends Controller
|
||||
'message' => 'sometimes|nullable|string',
|
||||
'builder_data' => 'sometimes|nullable|array',
|
||||
'status' => 'sometimes|boolean',
|
||||
'marketing' => 'sometimes|boolean',
|
||||
'mail_config' => 'sometimes|nullable|array',
|
||||
'mail_config.from_email' => 'nullable|email',
|
||||
'mail_config.from_name' => 'nullable|string|max:255',
|
||||
@@ -194,8 +199,11 @@ class EmailTemplateController extends Controller
|
||||
$metadata['mail'] = $data['mail_config'] ?? [];
|
||||
$data['metadata'] = $metadata;
|
||||
}
|
||||
if (array_key_exists('marketing', $data)) {
|
||||
$data['metadata'] = [...($data['metadata'] ?? $template->metadata ?? []), 'marketing' => (bool) $data['marketing']];
|
||||
}
|
||||
$variables = $data['variables'] ?? [];
|
||||
unset($data['mail_config'], $data['variables']);
|
||||
unset($data['mail_config'], $data['variables'], $data['marketing']);
|
||||
|
||||
$template->fill($data);
|
||||
$this->syncVariables($template, $variables);
|
||||
@@ -401,7 +409,8 @@ class EmailTemplateController extends Controller
|
||||
'variables' => array_values($t->variables()),
|
||||
'mail_config' => $t->mailConfig(),
|
||||
'placeholders' => $this->placeholders($t),
|
||||
...collect(EmailScenarioRegistry::describe($t->key, $t->scenario))->only(['registered', 'category', 'group', 'permissions', 'required_variables'])->all(),
|
||||
...collect(EmailScenarioRegistry::describe($t->key, $t->scenario))->only(['registered', 'group', 'permissions', 'required_variables'])->all(),
|
||||
'category' => $t->category(),
|
||||
'global_variables' => collect(MailGlobals::LABELS)->map(fn ($label, $key) => ['key' => $key, 'label' => $label])->values(),
|
||||
'created_at' => $t->created_at,
|
||||
'updated_at' => $t->updated_at,
|
||||
|
||||
@@ -13,14 +13,14 @@ class ServicePlanController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$plans = ServicePlan::with('plesk', 'dnsServer')->orderBy('sort_order')->orderBy('title')->get();
|
||||
$plans = ServicePlan::with('plesk', 'servers')->orderBy('sort_order')->orderBy('title')->get();
|
||||
|
||||
return response()->json(['error' => false, 'data' => $plans]);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$plan = ServicePlan::with('plesk', 'dnsServer')->find($id);
|
||||
$plan = ServicePlan::with('plesk', 'servers')->find($id);
|
||||
|
||||
if (!$plan) {
|
||||
return response()->json(['error' => true, 'message' => 'Không tìm thấy gói hosting.'], 404);
|
||||
@@ -33,12 +33,13 @@ class ServicePlanController extends Controller
|
||||
{
|
||||
$plan = ServicePlan::create($request->planAttributes());
|
||||
|
||||
$plan->syncServers($request->serverIds());
|
||||
$this->savePlesk($plan, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => 'Tạo gói hosting thành công.',
|
||||
'data' => $plan->load('plesk', 'dnsServer'),
|
||||
'data' => $plan->load('plesk', 'servers'),
|
||||
], 201);
|
||||
}
|
||||
|
||||
@@ -52,12 +53,13 @@ class ServicePlanController extends Controller
|
||||
|
||||
$plan->update($request->planAttributes());
|
||||
|
||||
$plan->syncServers($request->serverIds());
|
||||
$this->savePlesk($plan, $request->validated());
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => 'Cập nhật gói hosting thành công.',
|
||||
'data' => $plan->fresh(['plesk', 'dnsServer']),
|
||||
'data' => $plan->fresh(['plesk', 'servers']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ trait HandlesCampaignBulkActions
|
||||
$failedLogs = Mail::where('campaign_id', $campaign->id)
|
||||
->where('status', array_search('failed', Mail::STATUS_MAP, true))
|
||||
->lockForUpdate()
|
||||
->get(['id', 'to', 'customer_id']);
|
||||
->get(['id', 'to', 'customer_id', 'service_id']);
|
||||
if ($failedLogs->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -87,7 +87,7 @@ trait HandlesCampaignBulkActions
|
||||
])->save();
|
||||
|
||||
foreach ($failedLogs as $log) {
|
||||
SendMailCampaignRecipient::dispatch($campaign->id, $log->customer_id, $log->to)->afterCommit();
|
||||
SendMailCampaignRecipient::dispatch($campaign->id, $log->customer_id, $log->to, $log->service_id)->afterCommit();
|
||||
}
|
||||
$retried += $failedLogs->count();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin\MailCampaign\Concerns;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\MailCampaign;
|
||||
use App\Models\Service;
|
||||
use App\Support\RenewalCampaign;
|
||||
use App\Support\RenewalQuote;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
trait HandlesRenewalCampaigns
|
||||
{
|
||||
public function renewalServices(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'uuids' => 'required|array|min:1|max:200',
|
||||
'uuids.*' => 'string|exists:customers,uuid',
|
||||
'days' => 'nullable|integer|min:1|max:365',
|
||||
]);
|
||||
|
||||
$customerIds = Customer::whereIn('uuid', $data['uuids'])->pluck('id')->all();
|
||||
$today = now()->startOfDay();
|
||||
|
||||
$rows = RenewalQuote::dueServices($customerIds, (int) ($data['days'] ?? 30))
|
||||
->map(function (Service $service) use ($today) {
|
||||
$quote = RenewalQuote::forService($service);
|
||||
|
||||
return $quote ? [
|
||||
...$quote,
|
||||
'customer_uuid' => $service->customer->uuid,
|
||||
'customer_name' => $service->customer->fullname,
|
||||
'customer_email' => $service->customer->email,
|
||||
'days_left' => (int) $today->diffInDays($service->expires_at->copy()->startOfDay(), false),
|
||||
] : null;
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
return response()->json(['error' => false, 'data' => $rows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{service_id: int, months: int}> $selected
|
||||
* @return array<int, array{customer_id: int, email: string, service_id: int}>
|
||||
*/
|
||||
private function renewalJobs(MailCampaign $campaign, array $selected): array
|
||||
{
|
||||
if (! RenewalCampaign::isRenewalTemplate($campaign)) {
|
||||
throw ValidationException::withMessages(['renewal_services' => 'Template đã chọn không phải mẫu nhắc gia hạn.']);
|
||||
}
|
||||
|
||||
$services = Service::with('customer:id,email')
|
||||
->whereIn('id', array_column($selected, 'service_id'))
|
||||
->get();
|
||||
|
||||
return $services
|
||||
->filter(fn (Service $service) => $service->customer && trim((string) $service->customer->email) !== '')
|
||||
->map(fn (Service $service) => [
|
||||
'customer_id' => $service->customer->id,
|
||||
'email' => trim($service->customer->email),
|
||||
'service_id' => $service->id,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\Admin\MailCampaign;
|
||||
|
||||
use App\Http\Controllers\Api\Admin\MailCampaign\Concerns\HandlesCampaignBulkActions;
|
||||
use App\Http\Controllers\Api\Admin\MailCampaign\Concerns\HandlesRenewalCampaigns;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\Mail\SendMailCampaignRecipient;
|
||||
use App\Models\Customer;
|
||||
@@ -16,6 +17,7 @@ use App\Support\AutoEmailGate;
|
||||
use App\Support\MailAttachments;
|
||||
use App\Support\MailPlaceholders;
|
||||
use App\Support\MailSettings;
|
||||
use App\Support\RenewalCampaign;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -28,6 +30,7 @@ use Illuminate\Validation\ValidationException;
|
||||
final class MailCampaignController extends Controller
|
||||
{
|
||||
use HandlesCampaignBulkActions;
|
||||
use HandlesRenewalCampaigns;
|
||||
|
||||
private const MAX_EMAILS = 1000;
|
||||
|
||||
@@ -253,6 +256,9 @@ final class MailCampaignController extends Controller
|
||||
'emails' => 'nullable|array|max:' . self::MAX_EMAILS,
|
||||
'emails.*' => self::EMAIL_RULE,
|
||||
'name' => 'nullable|string|max:255',
|
||||
'renewal_services' => 'nullable|array|max:500',
|
||||
'renewal_services.*.service_id' => 'required|integer|exists:services,id',
|
||||
'renewal_services.*.months' => 'required|integer|min:1|max:600',
|
||||
'cc' => 'nullable|string|max:1000',
|
||||
'bcc' => 'nullable|string|max:1000',
|
||||
...MailAttachments::rules(),
|
||||
@@ -263,12 +269,25 @@ final class MailCampaignController extends Controller
|
||||
|
||||
$campaign = $this->draftCampaign($data);
|
||||
|
||||
$marketingOnly = $campaign->isMarketing();
|
||||
$recipients = $data['recipient_type'] === 'all'
|
||||
? $this->allCustomerRecipients($marketingOnly)
|
||||
: $this->selectedRecipients($data['uuids'] ?? [], $data['emails'] ?? [], $marketingOnly);
|
||||
$optedOut = $recipients['opted_out'];
|
||||
$recipients = $recipients['recipients'];
|
||||
$renewalJobs = [];
|
||||
$optedOut = 0;
|
||||
if (!empty($data['renewal_services'])) {
|
||||
$renewalJobs = $this->renewalJobs($campaign, $data['renewal_services']);
|
||||
$campaign->renewal_services = array_values(array_filter(
|
||||
$data['renewal_services'],
|
||||
fn (array $item) => in_array((int) $item['service_id'], array_column($renewalJobs, 'service_id'), true),
|
||||
));
|
||||
$campaign->variables = RenewalCampaign::withoutServiceValues($campaign->variables);
|
||||
$data['recipient_type'] = 'selected';
|
||||
$recipients = $renewalJobs;
|
||||
} else {
|
||||
$marketingOnly = $campaign->isMarketing();
|
||||
$resolved = $data['recipient_type'] === 'all'
|
||||
? $this->allCustomerRecipients($marketingOnly)
|
||||
: $this->selectedRecipients($data['uuids'] ?? [], $data['emails'] ?? [], $marketingOnly);
|
||||
$optedOut = $resolved['opted_out'];
|
||||
$recipients = $resolved['recipients'];
|
||||
}
|
||||
|
||||
if ($recipients === []) {
|
||||
return response()->json([
|
||||
@@ -299,8 +318,14 @@ final class MailCampaignController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($recipients as $email => $customerId) {
|
||||
SendMailCampaignRecipient::dispatch($campaign->id, $customerId, $email);
|
||||
if ($renewalJobs !== []) {
|
||||
foreach ($renewalJobs as $job) {
|
||||
SendMailCampaignRecipient::dispatch($campaign->id, $job['customer_id'], $job['email'], $job['service_id']);
|
||||
}
|
||||
} else {
|
||||
foreach ($recipients as $email => $customerId) {
|
||||
SendMailCampaignRecipient::dispatch($campaign->id, $customerId, $email);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -51,7 +51,7 @@ trait HandlesClientAuthentication
|
||||
if ($twoFactor->requiresChallenge($client, $request->input('device_token'))) {
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => ['two_factor' => $twoFactor->startLoginChallenge($client)],
|
||||
'data' => ['two_factor' => $twoFactor->startLoginChallenge($client, $request->ip(), $request->userAgent())],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Client\Service;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Service;
|
||||
use App\Support\RenewalPricing;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ServiceRenewalOptionsController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$service = Service::with(['orderItem.product', 'product', 'customProduct'])
|
||||
->where('uuid', $uuid)
|
||||
->where('customer_id', $request->user()->id)
|
||||
->firstOrFail();
|
||||
|
||||
$source = $service->customProduct ?? $service->orderItem?->product ?? $service->product;
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => [
|
||||
'options' => RenewalPricing::options($source),
|
||||
'addon_total' => RenewalPricing::addonTotal($service, 1),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ class ServicePlanRequest extends FormRequest
|
||||
'features.*' => 'string|distinct|max:100',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
'dns_manager_server_id' => 'nullable|integer|exists:dns_manager_servers,id',
|
||||
'server_ids' => 'nullable|array|max:10',
|
||||
'server_ids.*' => 'integer|distinct|exists:dns_manager_servers,id',
|
||||
'plesk_plan_name' => 'nullable|string|max:255',
|
||||
'note' => 'nullable|string',
|
||||
];
|
||||
@@ -48,10 +49,17 @@ class ServicePlanRequest extends FormRequest
|
||||
$validated = $this->validated();
|
||||
|
||||
return [
|
||||
...collect($validated)->except(['plesk_plan_name', 'note'])->all(),
|
||||
'dns_manager_server_id' => $validated['dns_manager_server_id'] ?? null,
|
||||
...collect($validated)->except(['plesk_plan_name', 'note', 'server_ids'])->all(),
|
||||
'sort_order' => $validated['sort_order'] ?? 0,
|
||||
'is_active' => $validated['is_active'] ?? true,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function serverIds(): array
|
||||
{
|
||||
return $this->validated()['server_ids'] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ class ClientUpdateCccdRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'cmnd' => 'required|string|max:50',
|
||||
'cmnd_before' => 'required|mimes:jpg,jpeg,png,webp|max:5120',
|
||||
'cmnd_after' => 'required|mimes:jpg,jpeg,png,webp|max:5120',
|
||||
'cmnd' => 'required|string|regex:/^\d{9}(\d{3})?$/',
|
||||
'cmnd_before' => 'required|file|mimetypes:image/webp|mimes:webp|max:5120|dimensions:min_width=300,min_height=300,max_width=2000,max_height=2000',
|
||||
'cmnd_after' => 'required|file|mimetypes:image/webp|mimes:webp|max:5120|dimensions:min_width=300,min_height=300,max_width=2000,max_height=2000',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -24,10 +24,17 @@ class ClientUpdateCccdRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'cmnd.required' => 'Số CMT/CCCD là bắt buộc',
|
||||
'cmnd.regex' => 'Số CMT/CCCD phải gồm 9 hoặc 12 chữ số',
|
||||
'cmnd_before.required' => 'Ảnh mặt trước là bắt buộc',
|
||||
'cmnd_before.image' => 'Ảnh mặt trước phải là hình ảnh',
|
||||
'cmnd_before.mimetypes' => 'Ảnh mặt trước phải là ảnh WebP',
|
||||
'cmnd_before.mimes' => 'Ảnh mặt trước phải là ảnh WebP',
|
||||
'cmnd_before.max' => 'Ảnh mặt trước tối đa 5MB',
|
||||
'cmnd_before.dimensions' => 'Ảnh mặt trước phải từ 300px đến 2000px mỗi cạnh',
|
||||
'cmnd_after.required' => 'Ảnh mặt sau là bắt buộc',
|
||||
'cmnd_after.image' => 'Ảnh mặt sau phải là hình ảnh',
|
||||
'cmnd_after.mimetypes' => 'Ảnh mặt sau phải là ảnh WebP',
|
||||
'cmnd_after.mimes' => 'Ảnh mặt sau phải là ảnh WebP',
|
||||
'cmnd_after.max' => 'Ảnh mặt sau tối đa 5MB',
|
||||
'cmnd_after.dimensions' => 'Ảnh mặt sau phải từ 300px đến 2000px mỗi cạnh',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,6 @@ class ServiceClientDetailResource extends JsonResource
|
||||
$vm = $this->resource->vmware;
|
||||
$product = $orderItem?->product ?? $this->resource->product;
|
||||
$custom = $this->resource->customProduct;
|
||||
$renewalSource = $custom ?? $product;
|
||||
$hosting = $this->resource->hostingAccount;
|
||||
$pleskCredentials = $hosting ? $this->resolvePleskCredentials($hosting) : null;
|
||||
$dnsZone = $this->resolveDnsZone();
|
||||
@@ -124,14 +123,6 @@ class ServiceClientDetailResource extends JsonResource
|
||||
'total' => (float) $order->total,
|
||||
'months' => (int) $orderItem->months,
|
||||
'billing_label' => $orderItem->billing_label,
|
||||
'cycle_price' => $custom
|
||||
? (float) $custom->unit_price * (int) $orderItem->months
|
||||
: (float) ($product?->pricings()
|
||||
->where('billing_cycle', $orderItem->billing_cycle)
|
||||
->where('is_active', 1)
|
||||
->value('price') ?? 0),
|
||||
'addon_total' => \App\Support\RenewalPricing::addonTotal($this->resource, 1),
|
||||
'renewal_options' => \App\Support\RenewalPricing::options($renewalSource),
|
||||
] : null,
|
||||
'vm' => $vm ? [
|
||||
'ip_address' => $vm->ipAddress?->ip ?? $vm->ip,
|
||||
@@ -145,6 +136,7 @@ class ServiceClientDetailResource extends JsonResource
|
||||
] : null,
|
||||
'hosting' => $hosting ? [
|
||||
'ip_address' => $hosting->server?->publicHost(),
|
||||
'server_ip' => $hosting->server?->ip_address,
|
||||
'plesk_url' => $hosting->server?->panelUrl(),
|
||||
'username' => ResponseCipher::encrypt($pleskCredentials['login'] ?? null),
|
||||
'password' => ResponseCipher::encrypt($pleskCredentials['password'] ?? null),
|
||||
|
||||
Reference in New Issue
Block a user