From 7fdcc01027a6105546c07c5727056c743ae5656b Mon Sep 17 00:00:00 2001 From: pbao9 Date: Tue, 10 Mar 2026 16:27:23 +0700 Subject: [PATCH] feat: Implement client status validation on login, refine password rules, and simplify customer resource status. --- .../Api/Admin/Customer/CustomerController.php | 7 +- .../Api/Admin/DnsManager/DomainController.php | 271 ++++++++++++++++++ .../Api/Admin/DnsManager/ServerController.php | 134 +++++++++ .../Api/Admin/Invoice/InvoiceController.php | 107 +++++++ app/Http/Requests/CreateCustomerRequest.php | 2 +- .../Resources/Api/Admin/CustomerResource.php | 7 +- app/Mail/InvoiceNotificationMail.php | 51 ++++ app/Models/DnsDomain.php | 2 +- app/Models/Invoice.php | 5 + app/Models/InvoiceAttachment.php | 25 ++ app/Providers/RepositoryProvider.php | 7 + app/Providers/RouteServiceProvider.php | 4 +- .../InvoiceAttachmentRepository.php | 34 +++ .../InvoiceAttachmentRepositoryInterface.php | 11 + app/Services/Api/Client/ClientAuthService.php | 13 + ...00001_create_invoice_attachments_table.php | 30 ++ .../emails/invoice-notification.blade.php | 102 +++++++ routes/api.php | 31 ++ 18 files changed, 831 insertions(+), 12 deletions(-) create mode 100644 app/Http/Controllers/Api/Admin/DnsManager/DomainController.php create mode 100644 app/Http/Controllers/Api/Admin/DnsManager/ServerController.php create mode 100644 app/Mail/InvoiceNotificationMail.php create mode 100644 app/Models/InvoiceAttachment.php create mode 100644 app/Repositories/InvoiceAttachment/InvoiceAttachmentRepository.php create mode 100644 app/Repositories/InvoiceAttachment/InvoiceAttachmentRepositoryInterface.php create mode 100644 database/migrations/2026_03_10_000001_create_invoice_attachments_table.php create mode 100644 resources/views/emails/invoice-notification.blade.php diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php index 3e26bfe..07226be 100644 --- a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php @@ -40,14 +40,15 @@ class CustomerController extends Controller public function index(Request $request): JsonResponse { - $perPage = min((int) $request->get('per_page', 15), 100); - $paginator = $this->customerRepository->filter( + $perPage = min((int) $request->get('per_page', 15), 500); + $query = $this->customerRepository->filter( $request->all(), [], ['hosting', 'vps', 'domain'], ['*'], - false + true ); + $paginator = $query->paginate($perPage); return response()->json([ 'error' => false, diff --git a/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php b/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php new file mode 100644 index 0000000..6976eee --- /dev/null +++ b/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php @@ -0,0 +1,271 @@ +get('per_page', 15), 500); + $filter = $request->get('filter'); + $status = $request->get('status'); + + $query = $this->domainRepository->model + ->with(['server', 'customer']) + ->newQuery(); + + if ($filter) { + $query->where('domain_name', 'like', "%{$filter}%"); + } + + if ($status !== null && $status !== '') { + $query->where('is_created', (int) $status); + } + + $paginator = $query->orderByDesc('id')->paginate($perPage); + + $items = $paginator->getCollection()->map(fn($domain) => $this->formatDomain($domain)); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => $items, + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + public function show(int $id): JsonResponse + { + $domain = $this->domainRepository->model + ->with(['server', 'customer']) + ->find($id); + + if (!$domain) { + return response()->json(['error' => true, 'message' => "Không tìm thấy domain #$id"], 404); + } + + return response()->json([ + 'error' => false, + 'data' => $this->formatDomain($domain), + ]); + } + + public function store(Request $request): JsonResponse + { + $request->validate([ + 'domain_name' => 'required|string|max:255', + 'dns_manager_server_id' => 'required|integer|exists:dns_manager_servers,id', + 'customer_ids' => 'nullable|array', + 'customer_ids.*' => 'integer|exists:customers,id', + 'is_created' => 'nullable|integer|in:0,1,2', + ]); + + DB::beginTransaction(); + try { + $domain = $this->domainRepository->create([ + 'domain_name' => $request->domain_name, + 'dns_manager_server_id' => $request->dns_manager_server_id, + 'is_created' => $request->is_created ?? 0, + ]); + + if (!empty($request->customer_ids)) { + $domain->customer()->sync($request->customer_ids); + } + + DB::commit(); + + return response()->json([ + 'error' => false, + 'data' => ['id' => $domain->id, 'domain_name' => $domain->domain_name], + ], 201); + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['error' => true, 'message' => $e->getMessage()], 500); + } + } + + public function update(int $id, Request $request): JsonResponse + { + $domain = $this->domainRepository->find($id); + + if (!$domain) { + return response()->json(['error' => true, 'message' => "Không tìm thấy domain #$id"], 404); + } + + $request->validate([ + 'domain_name' => 'sometimes|required|string|max:255', + 'dns_manager_server_id' => 'nullable|integer|exists:dns_manager_servers,id', + 'customer_ids' => 'nullable|array', + 'customer_ids.*' => 'integer|exists:customers,id', + 'is_created' => 'nullable|integer|in:0,1,2', + ]); + + DB::beginTransaction(); + try { + $this->domainRepository->update($id, $request->only([ + 'domain_name', + 'dns_manager_server_id', + 'is_created', + ])); + + // Sync customers (empty array = detach all) + if ($request->has('customer_ids')) { + $domain->customer()->sync($request->customer_ids ?? []); + } + + DB::commit(); + + return $this->show($id); + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['error' => true, 'message' => $e->getMessage()], 500); + } + } + + public function destroy(int $id): JsonResponse + { + $domain = $this->domainRepository->find($id); + + if (!$domain) { + return response()->json(['error' => true, 'message' => "Không tìm thấy domain #$id"], 404); + } + + DB::beginTransaction(); + try { + $domain->customer()->detach(); + $domain->record()->delete(); + $this->domainRepository->delete($id); + DB::commit(); + + return response()->json(['error' => false, 'message' => 'Đã xoá domain thành công']); + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['error' => true, 'message' => $e->getMessage()], 500); + } + } + + // ─── DNS Records ───────────────────────────────────────────────────────── + + // ─── Helpers ───────────────────────────────────────────────────────────── + + private function formatDomain($domain): array + { + return [ + 'id' => $domain->id, + 'domain_name' => $domain->domain_name, + 'dns_manager_server_id' => $domain->dns_manager_server_id, + 'server_name' => $domain->server?->name, + 'customer_ids' => $domain->customer->pluck('id')->toArray(), + 'customers' => $domain->customer->map(fn($c) => [ + 'id' => $c->id, + 'name' => $c->fullname, + 'email' => $c->email, + ])->values()->toArray(), + 'is_created' => $domain->is_created, + 'created_at' => $domain->created_at, + 'updated_at' => $domain->updated_at, + ]; + } + + // ─── DNS Records ───────────────────────────────────────────────────────── + + public function dnsIndex(int $domainId): JsonResponse + { + $domain = $this->domainRepository->find($domainId); + + if (!$domain) { + return response()->json(['error' => true, 'message' => "Không tìm thấy domain #$domainId"], 404); + } + + $records = $this->recordRepository->model + ->where('dns_manager_domain_id', $domainId) + ->orderBy('type') + ->get(); + + return response()->json(['error' => false, 'data' => $records]); + } + + public function dnsStore(int $domainId, Request $request): JsonResponse + { + $domain = $this->domainRepository->find($domainId); + + if (!$domain) { + return response()->json(['error' => true, 'message' => "Không tìm thấy domain #$domainId"], 404); + } + + $request->validate([ + 'type' => 'required|string|in:A,AAAA,CNAME,TXT,MX,NS,SRV', + 'name' => 'required|string|max:255', + 'value' => 'required|string', + 'ttl' => 'nullable|integer|min:60', + 'priority' => 'nullable|integer|min:0', + ]); + + $record = $this->recordRepository->create([ + 'dns_manager_domain_id' => $domainId, + 'type' => $request->type, + 'name' => $request->name, + 'value' => $request->value, + 'ttl' => $request->ttl ?? 300, + 'priority' => $request->priority, + ]); + + return response()->json(['error' => false, 'data' => $record], 201); + } + + public function dnsUpdate(int $domainId, int $recordId, Request $request): JsonResponse + { + $record = $this->recordRepository->model + ->where('dns_manager_domain_id', $domainId) + ->find($recordId); + + if (!$record) { + return response()->json(['error' => true, 'message' => "Không tìm thấy bản ghi DNS #$recordId"], 404); + } + + $request->validate([ + 'type' => 'required|string|in:A,AAAA,CNAME,TXT,MX,NS,SRV', + 'name' => 'required|string|max:255', + 'value' => 'required|string', + 'ttl' => 'nullable|integer|min:60', + 'priority' => 'nullable|integer|min:0', + ]); + + $this->recordRepository->update($recordId, $request->only(['type', 'name', 'value', 'ttl', 'priority'])); + + return response()->json(['error' => false, 'data' => $this->recordRepository->find($recordId)]); + } + + public function dnsDestroy(int $domainId, int $recordId): JsonResponse + { + $record = $this->recordRepository->model + ->where('dns_manager_domain_id', $domainId) + ->find($recordId); + + if (!$record) { + return response()->json(['error' => true, 'message' => "Không tìm thấy bản ghi DNS #$recordId"], 404); + } + + $this->recordRepository->delete($recordId); + + return response()->json(['error' => false, 'message' => 'Đã xoá bản ghi DNS']); + } +} diff --git a/app/Http/Controllers/Api/Admin/DnsManager/ServerController.php b/app/Http/Controllers/Api/Admin/DnsManager/ServerController.php new file mode 100644 index 0000000..15fd1d8 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/DnsManager/ServerController.php @@ -0,0 +1,134 @@ +get('per_page', 15), 500); + $filter = $request->get('filter'); + $status = $request->get('status'); + + $query = $this->serverRepository->model->newQuery(); + + if ($filter) { + $query->where(function ($q) use ($filter) { + $q->where('name', 'like', "%{$filter}%") + ->orWhere('ip_address', 'like', "%{$filter}%"); + }); + } + + if ($status) { + $query->where('status', $status); + } + + $paginator = $query->paginate($perPage); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => $paginator->items(), + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + public function show(int $id): JsonResponse + { + $server = $this->serverRepository->find($id); + + if (!$server) { + return response()->json(['error' => true, 'message' => "Không tìm thấy server #$id"], 404); + } + + return response()->json(['error' => false, 'data' => $server]); + } + + public function store(Request $request): JsonResponse + { + $request->validate([ + 'name' => 'required|string|max:255', + 'ip_address' => 'required|string|max:255', + 'username' => 'required|string|max:255', + 'password' => 'required|string|max:255', + 'api_key' => 'nullable|string', + 'status' => 'nullable|string|in:activated,Stopped', + ]); + + DB::beginTransaction(); + try { + $data = $request->all(); + $data['password'] = Crypt::encryptString($request->password); + $server = $this->serverRepository->create($data); + DB::commit(); + + return response()->json(['error' => false, 'data' => $server], 201); + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['error' => true, 'message' => $e->getMessage()], 500); + } + } + + public function update(int $id, Request $request): JsonResponse + { + $server = $this->serverRepository->find($id); + + if (!$server) { + return response()->json(['error' => true, 'message' => "Không tìm thấy server #$id"], 404); + } + + $request->validate([ + 'name' => 'required|string|max:255', + 'ip_address' => 'required|string|max:255', + 'username' => 'required|string|max:255', + 'password' => 'nullable|string|max:255', + 'api_key' => 'nullable|string', + 'status' => 'nullable|string|in:activated,Stopped', + ]); + + DB::beginTransaction(); + try { + $data = $request->except(['password']); + + if (!empty($request->password)) { + $data['password'] = Crypt::encryptString($request->password); + } + + $this->serverRepository->update($id, $data); + DB::commit(); + + return response()->json(['error' => false, 'data' => $this->serverRepository->find($id)]); + } catch (\Exception $e) { + DB::rollBack(); + return response()->json(['error' => true, 'message' => $e->getMessage()], 500); + } + } + + public function destroy(int $id): JsonResponse + { + $server = $this->serverRepository->find($id); + + if (!$server) { + return response()->json(['error' => true, 'message' => "Không tìm thấy server #$id"], 404); + } + + $this->serverRepository->delete($id); + + return response()->json(['error' => false, 'message' => 'Đã xoá server thành công']); + } +} diff --git a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php index 8716f56..4583361 100644 --- a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php +++ b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php @@ -5,14 +5,18 @@ namespace App\Http\Controllers\Api\Admin\Invoice; use App\Http\Controllers\Controller; use App\Http\Requests\InvoiceRequest; use App\Http\Resources\Api\Admin\InvoiceResource; +use App\Mail\InvoiceNotificationMail; use App\Models\Customer; use App\Models\Payment; use App\Repositories\Invoice\InvoiceRepositoryInterface; +use App\Repositories\InvoiceAttachment\InvoiceAttachmentRepositoryInterface; use App\Repositories\InvoiceItem\InvoiceItemRepositoryInterface; use App\Repositories\Order\OrderRepositoryInterface; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Storage; use Yajra\DataTables\Facades\DataTables; class InvoiceController extends Controller @@ -21,6 +25,7 @@ class InvoiceController extends Controller private readonly InvoiceRepositoryInterface $invoiceRepository, private readonly OrderRepositoryInterface $orderRepository, private readonly InvoiceItemRepositoryInterface $invoiceItemRepository, + private readonly InvoiceAttachmentRepositoryInterface $attachmentRepository, ) {} public function index(Request $request): JsonResponse @@ -253,4 +258,106 @@ class InvoiceController extends Controller ], 500); } } + + // ─── Attachments ──────────────────────────────────────────────────────────── + + public function listAttachments(int $id): JsonResponse + { + $invoice = $this->invoiceRepository->find($id); + if (!$invoice) { + return response()->json(['error' => true, 'message' => "Can't find invoice #$id"], 404); + } + + $attachments = $this->attachmentRepository->getByInvoice($id); + + return response()->json([ + 'error' => false, + 'data' => $attachments, + ]); + } + + public function uploadAttachment(Request $request, int $id): JsonResponse + { + $invoice = $this->invoiceRepository->find($id); + if (!$invoice) { + return response()->json(['error' => true, 'message' => "Can't find invoice #$id"], 404); + } + + $request->validate([ + 'file' => 'required|file|mimes:jpg,jpeg,png,gif,pdf,doc,docx,xls,xlsx|max:10240', + ]); + + $file = $request->file('file'); + $path = $file->store("invoice-attachments/{$id}", 'public'); + $uploader = $request->user()?->username ?? $request->user()?->name ?? 'admin'; + + $attachment = $this->attachmentRepository->create([ + 'invoice_id' => $id, + 'filename' => $file->getClientOriginalName(), + 'path' => $path, + 'disk' => 'public', + 'mime_type' => $file->getMimeType(), + 'size' => $file->getSize(), + 'uploaded_by' => $uploader, + ]); + + return response()->json([ + 'error' => false, + 'message' => 'File uploaded successfully', + 'data' => $attachment, + ], 201); + } + + public function deleteAttachment(int $id, int $attachmentId): JsonResponse + { + $invoice = $this->invoiceRepository->find($id); + if (!$invoice) { + return response()->json(['error' => true, 'message' => "Can't find invoice #$id"], 404); + } + + $attachment = $this->attachmentRepository->find($attachmentId); + if (!$attachment || $attachment->invoice_id !== $id) { + return response()->json(['error' => true, 'message' => 'Attachment not found'], 404); + } + + $this->attachmentRepository->deleteWithFile($attachmentId); + + return response()->json([ + 'error' => false, + 'message' => 'Attachment deleted', + ]); + } + + // ─── Send Mail ─────────────────────────────────────────────────────────────── + + public function sendMail(Request $request, int $id): JsonResponse + { + $request->validate([ + 'message' => 'nullable|string|max:1000', + ]); + + $invoice = $this->invoiceRepository->find($id); + if (!$invoice) { + return response()->json(['error' => true, 'message' => "Can't find invoice #$id"], 404); + } + + $invoice->load('customer'); + $customer = $invoice->customer; + if (!$customer || !$customer->email) { + return response()->json(['error' => true, 'message' => 'Customer email not found'], 422); + } + + $attachments = $this->attachmentRepository->getByInvoice($id); + $companyName = setting('general.company_name') ?: config('app.name'); + $message = $request->get('message', "Vui lòng xem thông tin hóa đơn #{$invoice->order_num} của bạn."); + + Mail::to($customer->email)->queue( + new InvoiceNotificationMail($invoice, $attachments, $customer->fullname, $message, $companyName) + ); + + return response()->json([ + 'error' => false, + 'message' => "Email đã được gửi tới {$customer->email}", + ]); + } } diff --git a/app/Http/Requests/CreateCustomerRequest.php b/app/Http/Requests/CreateCustomerRequest.php index 74b8df7..8b37023 100644 --- a/app/Http/Requests/CreateCustomerRequest.php +++ b/app/Http/Requests/CreateCustomerRequest.php @@ -24,7 +24,7 @@ class CreateCustomerRequest extends FormRequest return [ 'fullname' => 'required', 'email' => 'required|email', - 'password' => 'required', + 'password' => $this->isMethod('post') ? 'required|min:6' : 'nullable|min:6', 'company' => 'nullable', 'phone' => 'nullable', 'address' => 'nullable', diff --git a/app/Http/Resources/Api/Admin/CustomerResource.php b/app/Http/Resources/Api/Admin/CustomerResource.php index e357ff7..c72e850 100644 --- a/app/Http/Resources/Api/Admin/CustomerResource.php +++ b/app/Http/Resources/Api/Admin/CustomerResource.php @@ -15,12 +15,7 @@ class CustomerResource extends JsonResource 'email' => $this->email, 'phone' => $this->phone, 'company' => $this->company, - 'status' => match ((int) $this->status) { - 1 => 'active', - 2 => 'inactive', - 3 => 'suspended', - default => 'pending', - }, + 'status' => (int) $this->status, 'total' => (int) $this->total, 'created_at' => $this->created_at, ]; diff --git a/app/Mail/InvoiceNotificationMail.php b/app/Mail/InvoiceNotificationMail.php new file mode 100644 index 0000000..aab42f1 --- /dev/null +++ b/app/Mail/InvoiceNotificationMail.php @@ -0,0 +1,51 @@ +invoice->order_num} - {$this->companyName}", + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.invoice-notification', + with: [ + 'invoice' => $this->invoice, + 'attachments' => $this->attachments, + 'customerName' => $this->customerName, + 'message' => $this->message, + 'companyName' => $this->companyName, + ] + ); + } + + public function attachments(): array + { + return []; + } +} diff --git a/app/Models/DnsDomain.php b/app/Models/DnsDomain.php index 73f5e24..f4f7a5c 100644 --- a/app/Models/DnsDomain.php +++ b/app/Models/DnsDomain.php @@ -26,7 +26,7 @@ class DnsDomain extends Model public function customer() { - return $this->belongsToMany(Customer::class, 'dns_domain_user', 'domain_id', 'customer_id')->withTimestamps(); + return $this->belongsToMany(Customer::class, 'dns_domain_user', 'domain_id', 'user_id')->withTimestamps(); } public function server() diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index 7f9e7e2..d4a6895 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -34,6 +34,11 @@ class Invoice extends Model return $this->belongsTo(\App\Models\Order::class, 'id', 'invoice_id'); } + public function attachments() + { + return $this->hasMany(\App\Models\InvoiceAttachment::class); + } + public function getStatus() { return match ($this->attributes['status']) { diff --git a/app/Models/InvoiceAttachment.php b/app/Models/InvoiceAttachment.php new file mode 100644 index 0000000..4bbf35f --- /dev/null +++ b/app/Models/InvoiceAttachment.php @@ -0,0 +1,25 @@ +belongsTo(Invoice::class); + } + + public function getUrlAttribute(): string + { + return Storage::disk($this->disk)->url($this->path); + } +} diff --git a/app/Providers/RepositoryProvider.php b/app/Providers/RepositoryProvider.php index 934ac36..5b605b6 100644 --- a/app/Providers/RepositoryProvider.php +++ b/app/Providers/RepositoryProvider.php @@ -26,6 +26,8 @@ use App\Repositories\Hosting\HostingCachedRepository; use App\Repositories\Hosting\HostingRepositoryInterface; use App\Repositories\Invoice\InvoiceCachedRepository; use App\Repositories\Invoice\InvoiceRepositoryInterface; +use App\Repositories\InvoiceAttachment\InvoiceAttachmentRepository; +use App\Repositories\InvoiceAttachment\InvoiceAttachmentRepositoryInterface; use App\Repositories\InvoiceItem\InvoiceItemCachedRepository; use App\Repositories\InvoiceItem\InvoiceItemRepositoryInterface; use App\Repositories\Log\LogCachedRepository; @@ -174,6 +176,11 @@ class RepositoryProvider extends ServiceProvider InvoiceRepositoryInterface::class, InvoiceCachedRepository::class ); + //Invoice Attachment + $this->app->bind( + InvoiceAttachmentRepositoryInterface::class, + InvoiceAttachmentRepository::class + ); //Invoice Item $this->app->bind( InvoiceItemRepositoryInterface::class, diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index a45fbaa..59ae7da 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -70,7 +70,9 @@ class RouteServiceProvider extends ServiceProvider protected function configureRateLimiting() { RateLimiter::for('api', function (Request $request) { - return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); + // Admin SPA makes many parallel requests per page — use a higher limit for authenticated users + $limit = $request->user() ? 300 : 60; + return Limit::perMinute($limit)->by($request->user()?->id ?: $request->ip()); }); } } diff --git a/app/Repositories/InvoiceAttachment/InvoiceAttachmentRepository.php b/app/Repositories/InvoiceAttachment/InvoiceAttachmentRepository.php new file mode 100644 index 0000000..9ae69c1 --- /dev/null +++ b/app/Repositories/InvoiceAttachment/InvoiceAttachmentRepository.php @@ -0,0 +1,34 @@ +model->where('invoice_id', $invoiceId)->latest()->get(); + } + + public function deleteWithFile(int $id): bool + { + $attachment = $this->find($id); + if (!$attachment) { + return false; + } + + Storage::disk($attachment->disk)->delete($attachment->path); + $attachment->delete(); + + return true; + } +} diff --git a/app/Repositories/InvoiceAttachment/InvoiceAttachmentRepositoryInterface.php b/app/Repositories/InvoiceAttachment/InvoiceAttachmentRepositoryInterface.php new file mode 100644 index 0000000..0e46f3b --- /dev/null +++ b/app/Repositories/InvoiceAttachment/InvoiceAttachmentRepositoryInterface.php @@ -0,0 +1,11 @@ +user(); + if ((int)$client->status !== 1) { + $statusLabel = match ((int)$client->status) { + 0 => 'chờ xác thực', + 2 => 'tạm ngưng hoạt động', + 3 => 'bị khoá', + default => 'vô hiệu hoá', + }; + + throw ValidationException::withMessages([ + 'email' => ["Tài khoản của bạn đã {$statusLabel}, vui lòng liên hệ hotline."], + ]); + } + return [ 'client' => $client, 'token' => $token, diff --git a/database/migrations/2026_03_10_000001_create_invoice_attachments_table.php b/database/migrations/2026_03_10_000001_create_invoice_attachments_table.php new file mode 100644 index 0000000..ca4aba0 --- /dev/null +++ b/database/migrations/2026_03_10_000001_create_invoice_attachments_table.php @@ -0,0 +1,30 @@ +id(); + $table->unsignedBigInteger('invoice_id')->index(); + $table->string('filename'); // tên file gốc + $table->string('path'); // đường dẫn lưu trữ (storage) + $table->string('disk')->default('public'); + $table->string('mime_type', 100)->nullable(); + $table->unsignedBigInteger('size')->default(0); // bytes + $table->string('uploaded_by')->nullable(); // staff username/id + $table->timestamps(); + + $table->foreign('invoice_id')->references('id')->on('invoice')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('invoice_attachments'); + } +}; diff --git a/resources/views/emails/invoice-notification.blade.php b/resources/views/emails/invoice-notification.blade.php new file mode 100644 index 0000000..2b7ea6c --- /dev/null +++ b/resources/views/emails/invoice-notification.blade.php @@ -0,0 +1,102 @@ + + + + + + Thông báo hóa đơn + + + +
+
+

Thông báo hóa đơn

+
+ +
+

Xin chào {{ $customerName }},

+

{{ $message }}

+ +
+ + + + + + + + + + + + + + + + + + + + + +
Số hóa đơn#{{ $invoice->order_num }}
Ngày tạo{{ $invoice->date ? date('d/m/Y', $invoice->date) : '—' }}
Hạn thanh toán{{ $invoice->due_date ? date('d/m/Y', $invoice->due_date) : '—' }}
Tổng tiền{{ number_format($invoice->total) }} VNĐ
Trạng thái + @if($invoice->status == 2) + Đã thanh toán + @elseif($invoice->status == 1) + Chưa thanh toán + @else + Đã hủy + @endif +
+
+ + @if($attachments->isNotEmpty()) +
+

File đính kèm

+ +
+ @endif + + @if($invoice->note) +
+ Ghi chú: {{ $invoice->note }} +
+ @endif + +

Nếu bạn có bất kỳ thắc mắc nào, vui lòng liên hệ với chúng tôi để được hỗ trợ.

+

Trân trọng,
{{ $companyName }}

+
+ + +
+ + diff --git a/routes/api.php b/routes/api.php index 33ac06d..df5a87d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -11,6 +11,8 @@ use App\Http\Controllers\Api\Admin\Product\ProductCategoryController; use App\Http\Controllers\Api\Admin\Product\ProductController; use App\Http\Controllers\Api\Admin\VPS\VPSController; use App\Http\Controllers\Api\Admin\VCenter\VCenterController; +use App\Http\Controllers\Api\Admin\DnsManager\ServerController as DnsServerController; +use App\Http\Controllers\Api\Admin\DnsManager\DomainController as DnsDomainController; use App\Http\Services\Media; use App\Http\Services\vCenter as ServiceVCenter; use App\Models\ModuleVmware; @@ -125,6 +127,12 @@ Route::prefix('v1') Route::delete('{id}', [InvoiceController::class, 'destroy'])->middleware('perm:invoice.destroy'); Route::post('{id}/paid', [InvoiceController::class, 'markAsPaid'])->middleware('perm:invoice.edit'); Route::post('{id}/cancel', [InvoiceController::class, 'cancel'])->middleware('perm:invoice.edit'); + // Attachments + Route::get('{id}/attachments', [InvoiceController::class, 'listAttachments'])->middleware('perm:invoice.view'); + Route::post('{id}/attachments', [InvoiceController::class, 'uploadAttachment'])->middleware('perm:invoice.edit'); + Route::delete('{id}/attachments/{attachmentId}', [InvoiceController::class, 'deleteAttachment'])->middleware('perm:invoice.edit'); + // Send mail notification + Route::post('{id}/send-mail', [InvoiceController::class, 'sendMail'])->middleware('perm:invoice.edit'); }); // Payments (module: payment) @@ -157,6 +165,29 @@ Route::prefix('v1') Route::delete('node/{type}/{id}', [VCenterController::class, 'destroyNode'])->middleware('perm:module-vcenter.destroy'); }); + // DNS Manager — Domains (module: domain.manager) + Route::prefix('domains')->group(function () { + Route::get('/', [DnsDomainController::class, 'index'])->middleware('perm:domain.manager'); + Route::post('/', [DnsDomainController::class, 'store'])->middleware('perm:domain.manager'); + Route::get('{id}', [DnsDomainController::class, 'show'])->middleware('perm:domain.manager'); + Route::put('{id}', [DnsDomainController::class, 'update'])->middleware('perm:domain.manager'); + Route::delete('{id}', [DnsDomainController::class, 'destroy'])->middleware('perm:domain.manager'); + // DNS Records + Route::get('{id}/dns', [DnsDomainController::class, 'dnsIndex'])->middleware('perm:domain.manager'); + Route::post('{id}/dns', [DnsDomainController::class, 'dnsStore'])->middleware('perm:domain.manager'); + Route::put('{id}/dns/{recordId}', [DnsDomainController::class, 'dnsUpdate'])->middleware('perm:domain.manager'); + Route::delete('{id}/dns/{recordId}', [DnsDomainController::class, 'dnsDestroy'])->middleware('perm:domain.manager'); + }); + + // DNS Manager — Servers (module: domain.manager) + Route::prefix('dns-servers')->group(function () { + Route::get('/', [DnsServerController::class, 'index'])->middleware('perm:domain.manager'); + Route::post('/', [DnsServerController::class, 'store'])->middleware('perm:domain.manager'); + Route::get('{id}', [DnsServerController::class, 'show'])->middleware('perm:domain.manager'); + Route::put('{id}', [DnsServerController::class, 'update'])->middleware('perm:domain.manager'); + Route::delete('{id}', [DnsServerController::class, 'destroy'])->middleware('perm:domain.manager'); + }); + // vCenter Manager Power Operations (module: module-vcenter) Route::prefix('vcenter-manager')->group(function () { Route::post('vm/power-on', [App\Http\Controllers\admin\VCenterManagerController::class, 'powerOn'])->middleware('perm:module-vcenter.edit');