diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php index f764d4e..462d138 100644 --- a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php @@ -324,6 +324,64 @@ class CustomerController extends Controller } } + public function sendPasswordEmail(Request $request, string $uuid): JsonResponse + { + $request->validate([ + 'password' => 'required|string|min:8|max:64', + ]); + + $customer = $this->customerRepository->findBy('uuid', $uuid); + + if (!$customer) { + return response()->json([ + 'error' => true, + 'message' => "Can't find customer by UUID #" . $uuid, + ], 404); + } + + $password = (string) $request->input('password'); + + try { + DB::beginTransaction(); + + $this->customerRepository->update($customer->id, [ + 'password' => Hash::make($password), + ]); + + \Illuminate\Support\Facades\Mail::to($customer->email)->send( + new \App\Mail\Customer\PasswordResetByAdmin([ + 'fullname' => $customer->fullname, + 'email' => $customer->email, + 'password' => $password, + ]) + ); + + Log::create([ + 'agent' => 'admin', + 'client_ip' => $request->ip(), + 'description' => sprintf( + 'Admin gửi lại mật khẩu mới cho customer %s', + $customer->email, + ), + 'customer_id' => $customer->id, + ]); + + DB::commit(); + + return response()->json([ + 'error' => false, + 'message' => 'Đã gửi mật khẩu mới cho khách hàng.', + ]); + } catch (\Throwable $e) { + DB::rollBack(); + + return response()->json([ + 'error' => true, + 'message' => $e->getMessage(), + ], 500); + } + } + public function addCredit(Request $request, string $uuid): JsonResponse { $request->validate([ @@ -562,6 +620,82 @@ class CustomerController extends Controller ]); } + public function getDnsDomains(string $uuid): JsonResponse + { + $customer = $this->customerRepository->findBy('uuid', $uuid); + + if (!$customer) { + return response()->json([ + 'error' => true, + 'message' => "Can't find customer by UUID #" . $uuid, + ], 404); + } + + $domains = \App\Models\DnsDomain::query() + ->with('server:id,name,ip_address') + ->withCount(['ns', 'record']) + ->whereHas('customer', fn ($q) => $q->where('customers.id', $customer->id)) + ->orderByDesc('id') + ->get() + ->map(function ($d) { + return [ + 'id' => $d->id, + 'domain_name' => $d->domain_name, + 'is_created' => (int) $d->is_created, + 'plesk_id' => $d->plesk_id, + 'ns_count' => (int) ($d->ns_count ?? 0), + 'record_count' => (int) ($d->record_count ?? 0), + 'server' => $d->server ? [ + 'id' => $d->server->id, + 'name' => $d->server->name, + 'ip_address' => $d->server->ip_address, + ] : null, + 'created_at' => $d->created_at, + 'updated_at' => $d->updated_at, + ]; + }); + + return response()->json([ + 'error' => false, + 'data' => $domains, + ]); + } + + public function getDnsDomainRecords(string $uuid, int $domainId): JsonResponse + { + $customer = $this->customerRepository->findBy('uuid', $uuid); + + if (!$customer) { + return response()->json([ + 'error' => true, + 'message' => "Can't find customer by UUID #" . $uuid, + ], 404); + } + + $owns = \Illuminate\Support\Facades\DB::table('dns_domain_user') + ->where('domain_id', $domainId) + ->where('user_id', $customer->id) + ->exists(); + + if (!$owns) { + return response()->json([ + 'error' => true, + 'message' => 'Domain không thuộc về khách hàng này', + ], 403); + } + + $records = \App\Models\DnsRecord::query() + ->where('dns_manager_domain_id', $domainId) + ->orderBy('type') + ->orderBy('name') + ->get(); + + return response()->json([ + 'error' => false, + 'data' => $records, + ]); + } + public function getServices(string $uuid, Request $request): JsonResponse { $services = $this->serviceRepository->filter( diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index 3a4db1d..cc28e31 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -4,12 +4,14 @@ namespace App\Http\Controllers\Api\Client; use App\Http\Controllers\Controller; use App\Http\Requests\Api\Client\ClientAuthRequest; +use App\Http\Requests\Api\Client\ClientChangePasswordRequest; use App\Http\Requests\Api\Client\ClientRegisterRequest; use App\Http\Requests\Api\Client\ClientUpdateRequest; use App\Http\Resources\Api\Client\ClientResource; use App\Services\ActivityLogger; use App\Services\Api\Client\ClientAuthService; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Hash; use Tymon\JWTAuth\Facades\JWTAuth; use Tymon\JWTAuth\Exceptions\JWTException; @@ -307,6 +309,68 @@ class ClientController extends Controller } } + public function forgotPassword(Request $request) + { + $request->validate([ + 'email' => ['required', 'email', 'max:255'], + ]); + + $email = strtolower(trim((string) $request->input('email'))); + + $customer = \App\Models\Customer::whereRaw('LOWER(email) = ?', [$email])->first(); + + if (! $customer) { + // Do not disclose whether the email exists. + return response()->json([ + 'error' => false, + 'message' => 'Nếu email tồn tại, mật khẩu mới đã được gửi đến hộp thư của bạn.', + ]); + } + + $alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*'; + $newPassword = ''; + $max = strlen($alphabet) - 1; + for ($i = 0; $i < 16; $i++) { + $newPassword .= $alphabet[random_int(0, $max)]; + } + + try { + \Illuminate\Support\Facades\DB::beginTransaction(); + + $customer->password = Hash::make($newPassword); + $customer->save(); + + \Illuminate\Support\Facades\Mail::to($customer->email)->send( + new \App\Mail\Customer\PasswordResetByAdmin([ + 'fullname' => $customer->fullname, + 'email' => $customer->email, + 'password' => $newPassword, + ]) + ); + + $this->activityLogger->log( + event: 'password_forgot', + logName: 'Customer', + description: "Customer {$customer->email} yêu cầu cấp lại mật khẩu", + subject: $customer, + ); + + \Illuminate\Support\Facades\DB::commit(); + } catch (\Throwable) { + \Illuminate\Support\Facades\DB::rollBack(); + + return response()->json([ + 'error' => true, + 'message' => 'Không thể gửi email, vui lòng thử lại sau.', + ], 500); + } + + return response()->json([ + 'error' => false, + 'message' => 'Mật khẩu mới đã được gửi đến email của bạn.', + ]); + } + public function update(ClientUpdateRequest $request) { $user = auth('client')->user(); @@ -335,4 +399,31 @@ class ClientController extends Controller ], ]); } + + public function changePassword(ClientChangePasswordRequest $request) + { + $user = auth('client')->user(); + + if (!Hash::check($request->input('current_password'), $user->password)) { + return response()->json([ + 'error' => true, + 'message' => 'Mật khẩu hiện tại không chính xác', + ], 422); + } + + $user->password = bcrypt($request->input('password')); + $user->save(); + + $this->activityLogger->log( + event: 'password_change', + logName: 'Customer', + description: "Customer {$user->email} đã đổi mật khẩu", + subject: $user, + ); + + return response()->json([ + 'error' => false, + 'message' => 'Đổi mật khẩu thành công', + ]); + } } diff --git a/app/Http/Requests/Api/Client/ClientChangePasswordRequest.php b/app/Http/Requests/Api/Client/ClientChangePasswordRequest.php new file mode 100644 index 0000000..98fbe1c --- /dev/null +++ b/app/Http/Requests/Api/Client/ClientChangePasswordRequest.php @@ -0,0 +1,32 @@ +check(); + } + + public function rules(): array + { + return [ + 'current_password' => 'required|string', + 'password' => 'required|string|min:8|confirmed|different:current_password', + ]; + } + + public function messages(): array + { + return [ + 'current_password.required' => 'Mật khẩu hiện tại là bắt buộc', + 'password.required' => 'Mật khẩu mới là bắt buộc', + 'password.min' => 'Mật khẩu mới phải có ít nhất 8 ký tự', + 'password.confirmed' => 'Xác nhận mật khẩu không khớp', + 'password.different' => 'Mật khẩu mới phải khác mật khẩu hiện tại', + ]; + } +} diff --git a/app/Mail/Customer/PasswordResetByAdmin.php b/app/Mail/Customer/PasswordResetByAdmin.php new file mode 100644 index 0000000..2f1eecc --- /dev/null +++ b/app/Mail/Customer/PasswordResetByAdmin.php @@ -0,0 +1,66 @@ +payload()['company_name'] ?? config('app.name', 'The Gioi So'); + + return new Envelope( + subject: 'Mật khẩu của bạn đã được cập nhật — ' . $appName, + ); + } + + public function content(): Content + { + $data = $this->payload(); + + return new Content( + view: 'emails.customer.password-reset-admin', + with: [ + 'appName' => $data['company_name'] ?? config('app.name', 'The Gioi So'), + 'fullName' => $data['fullname'] ?? 'Quý khách', + 'email' => $data['email'] ?? '', + 'password' => $data['password'] ?? '', + 'loginUrl' => $data['login_url'] ?? rtrim((string) env('FRONTEND_URL', config('app.url')), '/') . '/dang-nhap', + 'supportEmail' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'), + 'supportPhone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'), + ], + ); + } + + public function attachments(): array + { + return []; + } + + private function payload(): array + { + try { + $setting = Setting::where('key', 'setting')->first(); + $settingValue = $setting ? json_decode((string) $setting->value, true) : []; + } catch (\Throwable) { + $settingValue = []; + } + + return array_merge([ + 'company_name' => $settingValue['company'] ?? config('app.name', 'The Gioi So'), + 'support_email' => $settingValue['email'] ?? config('mail.support_email', 'support@thegioiso.com'), + 'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')), + 'login_url' => rtrim((string) env('FRONTEND_URL', config('app.url')), '/') . '/dang-nhap', + ], $this->data); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index b54b43d..807e2aa 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -124,6 +124,27 @@ class Customer extends Authenticatable implements JWTSubject return $this->hasMany(ModuleVmware::class); } + /** + * Quan hệ N-N với DnsDomain (DNS Manager) qua bảng pivot dns_domain_user. + */ + public function dnsDomain() + { + return $this->belongsToMany( + DnsDomain::class, + 'dns_domain_user', + 'user_id', + 'domain_id', + )->withTimestamps(); + } + + /** + * Quan hệ với Service (tbl.services). + */ + public function service() + { + return $this->hasMany(Service::class); + } + /** * Quan hệ với Invoice. */ diff --git a/app/Models/DnsDomain.php b/app/Models/DnsDomain.php index 8f6b8b1..b452ad6 100644 --- a/app/Models/DnsDomain.php +++ b/app/Models/DnsDomain.php @@ -14,17 +14,11 @@ class DnsDomain extends Model protected $fillable = [ 'domain_name', - 'user_id', 'dns_manager_server_id', 'is_created', 'plesk_id', ]; - public function user() - { - return $this->belongsTo(User::class); - } - public function customer() { return $this->belongsToMany(Customer::class, 'dns_domain_user', 'domain_id', 'user_id')->withTimestamps(); diff --git a/app/Repositories/Customer/CustomerRepository.php b/app/Repositories/Customer/CustomerRepository.php index 042af6d..75322a0 100644 --- a/app/Repositories/Customer/CustomerRepository.php +++ b/app/Repositories/Customer/CustomerRepository.php @@ -52,6 +52,54 @@ class CustomerRepository extends BaseRepository implements CustomerRepositoryInt if (isset($data['phone']) && ! empty($data['phone'])) { $model = $model->where("phone", "LIKE", "%" . $data['phone'] . "%"); } + if (isset($data['service_type']) && ! empty($data['service_type'])) { + $types = is_array($data['service_type']) + ? $data['service_type'] + : array_filter(array_map('trim', explode(',', (string) $data['service_type']))); + + $simpleMap = [ + 'hosting' => 'hosting', + 'vps' => 'vps', + 'domain' => 'domain', + 'service' => 'service', + ]; + + $simpleRelations = array_values(array_intersect_key($simpleMap, array_flip($types))); + $hasDns = in_array('dns', $types, true); + + if (! empty($simpleRelations) || $hasDns) { + $dnsIds = $hasDns + ? \Illuminate\Support\Facades\DB::table('dns_domain_user')->pluck('user_id') + : collect(); + + $model = $model->where(function ($q) use ($simpleRelations, $dnsIds) { + foreach ($simpleRelations as $relation) { + $q->orWhereHas($relation); + } + if ($dnsIds->isNotEmpty()) { + $q->orWhereIn('customers.id', $dnsIds); + } + }); + } + } + + if (isset($data['domain']) && ! empty($data['domain'])) { + $domain = trim((string) $data['domain']); + $dnsCustomerIds = \Illuminate\Support\Facades\DB::table('dns_domain_user') + ->join('dns_manager_domain', 'dns_manager_domain.id', '=', 'dns_domain_user.domain_id') + ->where('dns_manager_domain.domain_name', 'like', "%{$domain}%") + ->pluck('dns_domain_user.user_id'); + + $model = $model->where(function ($q) use ($domain, $dnsCustomerIds) { + $q->whereHas('domain', function ($sub) use ($domain) { + $sub->where('name', 'like', "%{$domain}%"); + }); + if ($dnsCustomerIds->isNotEmpty()) { + $q->orWhereIn('customers.id', $dnsCustomerIds); + } + }); + } + if (isset($data['status']) && ! empty($data['status']) && $data['status'] != 'full') { $status = (string) $data['status']; $statusValue = is_numeric($status) diff --git a/app/Services/Imports/AdminZoneDnsSqlImporter.php b/app/Services/Imports/AdminZoneDnsSqlImporter.php index 431afb2..101b049 100644 --- a/app/Services/Imports/AdminZoneDnsSqlImporter.php +++ b/app/Services/Imports/AdminZoneDnsSqlImporter.php @@ -193,10 +193,6 @@ class AdminZoneDnsSqlImporter 'updated_at' => $this->normalizeDateTime($row['updated_at'] ?? null) ?? now(), ]; - if (Schema::hasColumn('dns_manager_domain', 'user_id')) { - $payload['user_id'] = $existing?->user_id ?? 0; - } - if ($preview) { $summary['dns_domains'][$existing ? 'updated' : 'created']++; continue; diff --git a/database/migrations/2026_06_22_000001_drop_user_id_from_dns_manager_domain.php b/database/migrations/2026_06_22_000001_drop_user_id_from_dns_manager_domain.php new file mode 100644 index 0000000..366303b --- /dev/null +++ b/database/migrations/2026_06_22_000001_drop_user_id_from_dns_manager_domain.php @@ -0,0 +1,31 @@ +dropIndex(['user_id']); + } catch (\Throwable) { + // index may have a different generated name; ignore + } + $table->dropColumn('user_id'); + }); + } + } + + public function down(): void + { + if (Schema::hasTable('dns_manager_domain') && ! Schema::hasColumn('dns_manager_domain', 'user_id')) { + Schema::table('dns_manager_domain', function (Blueprint $table) { + $table->tinyInteger('user_id')->default(0)->index()->after('domain_name'); + }); + } + } +}; diff --git a/resources/views/emails/customer/password-reset-admin.blade.php b/resources/views/emails/customer/password-reset-admin.blade.php new file mode 100644 index 0000000..d054de7 --- /dev/null +++ b/resources/views/emails/customer/password-reset-admin.blade.php @@ -0,0 +1,63 @@ + + +
+ + ++ Xin chào {{ $fullName }}, +
++ Quản trị viên của {{ $appName }} vừa cấp lại mật khẩu mới cho tài khoản của bạn. + Vui lòng sử dụng thông tin bên dưới để đăng nhập và đổi lại mật khẩu của bạn ngay sau đó. +
+ ++ Nếu bạn không yêu cầu thao tác này, vui lòng liên hệ ngay với chúng tôi qua + {{ $supportEmail }}. +
+