diff --git a/.env.example b/.env.example index 88c1cfa..b54a59a 100644 --- a/.env.example +++ b/.env.example @@ -33,7 +33,7 @@ CKFINDER_LICENSE=WS8SXEQQXPN8MYTF51SX1VFFUW3B6 PASSPORT_PERSONAL_ACCESS_CLIENT_ID=9c1e5bea-4c4e-4c86-8a29-91e7444d3897 PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET=T3TGImDGDT0p2I9wD4ckunLZV9iHyJbl378s83HQ -BROADCAST_DRIVER=redis +BROADCAST_DRIVER=pusher CACHE_DRIVER=redis FILESYSTEM_DISK=public QUEUE_CONNECTION=database @@ -64,9 +64,9 @@ AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false -PUSHER_APP_ID=1759841 -PUSHER_APP_KEY=883447e9a4445d181dbe -PUSHER_APP_SECRET=07c2261b3cb9e82ba3da +PUSHER_APP_ID=2158258 +PUSHER_APP_KEY=08113c904934c0bc7581 +PUSHER_APP_SECRET=8402ee6ff613513a1dad PUSHER_APP_CLUSTER=ap1 MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" diff --git a/app/Events/TicketReplyCreated.php b/app/Events/TicketReplyCreated.php new file mode 100644 index 0000000..1c7c4a9 --- /dev/null +++ b/app/Events/TicketReplyCreated.php @@ -0,0 +1,43 @@ +ticketNum), + new Channel('notifications.client.' . $this->customerId), + new Channel('admin-notifications'), + ]; + } + + public function broadcastAs(): string + { + return 'ticket.reply.created'; + } + + public function broadcastWith(): array + { + return [ + 'ticket_num' => $this->ticketNum, + 'customer_id' => $this->customerId, + 'reply_id' => $this->replyId, + ]; + } +} diff --git a/app/Helpers/NumberToWords.php b/app/Helpers/NumberToWords.php new file mode 100644 index 0000000..e0b809f --- /dev/null +++ b/app/Helpers/NumberToWords.php @@ -0,0 +1,76 @@ + 0) { + $chunk = $n % 1000; + if ($chunk !== 0) { + $text = self::readHundreds($chunk); + $parts[] = $text . ($groups[$groupIdx] ? ' ' . $groups[$groupIdx] : ''); + } + $n = intdiv($n, 1000); + $groupIdx++; + } + + return implode(' ', array_reverse($parts)); + } + + private static function readHundreds(int $n): string + { + if ($n < 10) return self::$ones[$n]; + if ($n < 20) return 'mười' . ($n > 10 ? ($n === 15 ? ' lăm' : ' ' . self::$ones[$n - 10]) : ''); + + $h = intdiv($n, 100); + $t = intdiv($n % 100, 10); + $u = $n % 10; + + $result = ''; + + if ($h > 0) $result .= self::$ones[$h] . ' trăm'; + + if ($t === 0 && $u === 0) return $result; + + if ($t === 0) { + // hàng trăm > 0, hàng chục = 0 + $result .= ' linh ' . self::$ones[$u]; + } elseif ($t === 1) { + $result .= ($h > 0 ? ' ' : '') . 'mười' . ($u > 0 ? ($u === 5 ? ' lăm' : ' ' . self::$ones[$u]) : ''); + } else { + $result .= ($h > 0 ? ' ' : '') . self::$ones[$t] . ' mươi'; + if ($u === 1) $result .= ' mốt'; + elseif ($u === 4) $result .= ' tư'; + elseif ($u === 5) $result .= ' lăm'; + elseif ($u > 0) $result .= ' ' . self::$ones[$u]; + } + + return $result; + } +} diff --git a/app/Http/Controllers/Api/Admin/Addon/AddonController.php b/app/Http/Controllers/Api/Admin/Addon/AddonController.php new file mode 100644 index 0000000..7da0ce9 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Addon/AddonController.php @@ -0,0 +1,164 @@ +get('per_page', 15), 100); + + $query = Addon::when($request->get('filter'), fn($q, $f) => + $q->where('name', 'like', "%{$f}%") + )->when($request->filled('type'), fn($q) => + $q->where('type', $request->get('type')) + )->when($request->filled('status'), fn($q) => + $q->where('status', $request->boolean('status')) + )->orderBy('sort_order')->orderBy('name'); + + if ($request->boolean('all')) { + $items = $query->get(); + return response()->json([ + 'error' => false, + 'data' => $items, + ]); + } + + $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 + { + $addon = Addon::find($id); + + if (!$addon) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy addon.'], 404); + } + + return response()->json([ + 'error' => false, + 'data' => $addon, + ]); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'type' => ['required', 'string', Rule::in(AddonType::values())], + 'code' => 'required|string|max:50|unique:product_addon,code', + 'name' => 'required|string|max:100', + 'prefix' => 'nullable|string|max:50', + 'min' => 'required|integer|min:0', + 'max' => 'required|integer|min:0', + 'step' => 'required|integer|min:1', + 'price' => 'required|integer|min:0', + 'input_type' => ['required', 'string', Rule::in(['number', 'slider', 'select'])], + 'status' => 'boolean', + 'sort_order' => 'nullable|integer', + ]); + + $addon = Addon::create($validated); + + return response()->json([ + 'error' => false, + 'message' => 'Tạo addon thành công.', + 'data' => $addon, + ], 201); + } + + public function update(Request $request, int $id): JsonResponse + { + $addon = Addon::find($id); + + if (!$addon) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy addon.'], 404); + } + + $validated = $request->validate([ + 'type' => ['sometimes', 'required', 'string', Rule::in(AddonType::values())], + 'code' => ['sometimes', 'required', 'string', 'max:50', Rule::unique('product_addon', 'code')->ignore($id)], + 'name' => 'sometimes|required|string|max:100', + 'prefix' => 'nullable|string|max:50', + 'min' => 'sometimes|required|integer|min:0', + 'max' => 'sometimes|required|integer|min:0', + 'step' => 'sometimes|required|integer|min:1', + 'price' => 'sometimes|required|integer|min:0', + 'input_type' => ['sometimes', 'required', 'string', Rule::in(['number', 'slider', 'select'])], + 'status' => 'boolean', + 'sort_order' => 'nullable|integer', + ]); + + $addon->update($validated); + + return response()->json([ + 'error' => false, + 'message' => 'Cập nhật addon thành công.', + 'data' => $addon->fresh(), + ]); + } + + public function destroy(int $id): JsonResponse + { + $addon = Addon::find($id); + + if (!$addon) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy addon.'], 404); + } + + $addon->delete(); + + return response()->json([ + 'error' => false, + 'message' => 'Xoá addon thành công.', + ]); + } + + public function bulkUpdateStatus(Request $request): JsonResponse + { + $validated = $request->validate([ + 'ids' => 'required|array', + 'ids.*' => 'integer', + 'status' => 'required|boolean', + ]); + + $updated = Addon::whereIn('id', $validated['ids']) + ->update(['status' => $validated['status']]); + + $label = $validated['status'] ? 'kích hoạt' : 'ẩn'; + + return response()->json([ + 'error' => false, + 'message' => "Đã {$label} {$updated} addon.", + ]); + } + + public function bulkDestroy(Request $request): JsonResponse + { + $ids = $request->validate(['ids' => 'required|array', 'ids.*' => 'integer'])['ids']; + + $deleted = Addon::whereIn('id', $ids)->delete(); + + return response()->json([ + 'error' => false, + 'message' => "Đã xoá {$deleted} addon.", + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php index afca314..40352e5 100644 --- a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php @@ -10,6 +10,7 @@ use App\Models\Customer; use App\Models\Invoice; use App\Models\Order; use App\Models\Transaction; +use App\Models\Log; use App\Repositories\Customer\CustomerRepositoryInterface; use App\Repositories\Invoice\InvoiceRepositoryInterface; use App\Repositories\InvoiceItem\InvoiceItemRepositoryInterface; @@ -23,6 +24,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Str; class CustomerController extends Controller @@ -370,6 +372,87 @@ class CustomerController extends Controller } } + public function addTotal(Request $request, string $uuid): JsonResponse + { + $request->validate([ + 'amount' => 'required|numeric|min:1', + 'note' => 'required|string|max:1000', + ]); + + $customer = $this->customerRepository->findBy('uuid', $uuid); + + if (!$customer) { + return response()->json([ + 'error' => true, + 'message' => "Can't find customer by UUID #" . $uuid, + ], 404); + } + + $admin = Auth::guard('admin')->user(); + if (!$admin) { + return response()->json([ + 'error' => true, + 'message' => 'Unauthorized', + ], 401); + } + + try { + DB::beginTransaction(); + + $amount = (int) $request->amount; + + $transaction = Transaction::create([ + 'uuid' => (string) Str::uuid(), + 'trans' => 'ADMIN' . now()->format('YmdHis'), + 'amount' => $amount, + 'customer_id' => $customer->id, + 'user_id' => $admin->id, + 'status' => 1, + 'type' => 'admin_add_total', + 'note' => $request->string('note')->trim()->toString(), + ]); + + $customer->update([ + 'total' => (int) $customer->total + $amount, + ]); + + Log::create([ + 'agent' => 'admin', + 'client_ip' => $request->ip(), + 'description' => sprintf( + '%s cộng tiền trực tiếp cho customer %s: %s VNĐ - %s', + $admin->username ?? $admin->email, + $customer->email, + number_format($amount), + $transaction->note, + ), + 'customer_id' => $customer->id, + ]); + + DB::commit(); + + return response()->json([ + 'error' => false, + 'message' => 'Total updated successfully', + 'data' => [ + 'transaction' => [ + 'uuid' => $transaction->uuid, + 'amount' => $transaction->amount, + 'note' => $transaction->note, + 'admin' => $admin->username ?? $admin->email, + ], + ], + ]); + } catch (\Exception $e) { + DB::rollBack(); + + return response()->json([ + 'error' => true, + 'message' => $e->getMessage(), + ], 500); + } + } + public function getProducts(string $uuid): JsonResponse { $customer = $this->customerRepository->findBy('uuid', $uuid); @@ -446,7 +529,10 @@ class CustomerController extends Controller ], 404); } - $transactions = $this->transactionRepository->filter(['customer_id' => $customer->id]); + $transactions = $this->transactionRepository->filter( + ['customer_id' => $customer->id], + ['user'] + ); return response()->json([ 'error' => false, diff --git a/app/Http/Controllers/Api/Admin/Docs/PostCategoryController.php b/app/Http/Controllers/Api/Admin/Docs/PostCategoryController.php new file mode 100644 index 0000000..bf9b2e8 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Docs/PostCategoryController.php @@ -0,0 +1,128 @@ +get('per_page', 15), 100); + + $query = PostCategory::query() + ->withCount('posts') + ->when($request->filled('keyword'), fn ($q) => $q->where('name', 'like', '%' . $request->string('keyword') . '%')) + ->orderBy('sort_order') + ->orderBy('name'); + + if ($request->boolean('all')) { + return response()->json([ + 'error' => false, + 'data' => PostCategoryResource::collection($query->get()), + ]); + } + + $paginator = $query->paginate($perPage); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => PostCategoryResource::collection($paginator->items()), + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + public function store(PostCategoryStoreRequest $request): JsonResponse + { + $data = $request->validated(); + $data['slug'] = $data['slug'] ?: GeneratesUniqueSlug::forModel(PostCategory::class, $data['name']); + + if ($request->filled('slug')) { + $data['slug'] = GeneratesUniqueSlug::forModel(PostCategory::class, $data['slug']); + } + + $category = PostCategory::create($data); + $this->revalidation->revalidateDocs(); + + return response()->json([ + 'error' => false, + 'message' => 'Tạo danh mục thành công.', + 'data' => new PostCategoryResource($category), + ], 201); + } + + public function show(int $id): JsonResponse + { + $category = PostCategory::withCount('posts')->find($id); + + if (!$category) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy danh mục.'], 404); + } + + return response()->json([ + 'error' => false, + 'data' => new PostCategoryResource($category), + ]); + } + + public function update(PostCategoryUpdateRequest $request, int $id): JsonResponse + { + $category = PostCategory::find($id); + + if (!$category) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy danh mục.'], 404); + } + + $data = $request->validated(); + $slugSource = $data['slug'] ?: $data['name']; + $data['slug'] = GeneratesUniqueSlug::forModel(PostCategory::class, $slugSource, $category->id); + + $category->update($data); + $this->revalidation->revalidateDocs(); + + return response()->json([ + 'error' => false, + 'message' => 'Cập nhật danh mục thành công.', + 'data' => new PostCategoryResource($category->fresh()), + ]); + } + + public function destroy(int $id): JsonResponse + { + $category = PostCategory::withCount('posts')->find($id); + + if (!$category) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy danh mục.'], 404); + } + + if ($category->posts_count > 0) { + return response()->json([ + 'error' => true, + 'message' => 'Không thể xoá danh mục đang có bài viết.', + ], 422); + } + + $category->delete(); + $this->revalidation->revalidateDocs(); + + return response()->json([ + 'error' => false, + 'message' => 'Xoá danh mục thành công.', + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/Docs/PostController.php b/app/Http/Controllers/Api/Admin/Docs/PostController.php new file mode 100644 index 0000000..80f24e4 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Docs/PostController.php @@ -0,0 +1,161 @@ +get('per_page', 15), 100); + $sort = $request->string('sort', 'latest')->toString(); + + $query = Post::query() + ->with('category') + ->when( + $request->filled('keyword'), + fn ($q) => $q->where(function ($subQuery) use ($request) { + $keyword = $request->string('keyword')->toString(); + $subQuery + ->where('title', 'like', "%{$keyword}%") + ->orWhere('excerpt', 'like', "%{$keyword}%") + ->orWhere('content', 'like', "%{$keyword}%"); + }) + ) + ->when($request->filled('category_id'), fn ($q) => $q->where('post_category_id', (int) $request->get('category_id'))) + ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')->toString())) + ->when($request->filled('is_featured'), fn ($q) => $q->where('is_featured', $request->boolean('is_featured'))); + + match ($sort) { + 'title_asc' => $query->orderBy('title'), + 'title_desc' => $query->orderByDesc('title'), + 'published_at_asc', 'oldest' => $query->orderBy('published_at')->orderBy('id'), + 'most_viewed' => $query->orderByDesc('view_count')->orderByDesc('id'), + default => $query->orderByDesc('published_at')->orderByDesc('id'), + }; + + $paginator = $query->paginate($perPage); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => PostResource::collection($paginator->items()), + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + public function store(PostStoreRequest $request): JsonResponse + { + $data = $request->validated(); + $slugSource = $data['slug'] ?: $data['title']; + $data['slug'] = GeneratesUniqueSlug::forModel(Post::class, $slugSource); + $data['is_featured'] = (bool) ($data['is_featured'] ?? false); + $data['view_count'] = (int) ($data['view_count'] ?? 0); + + $post = Post::create($data); + $this->revalidation->revalidateDocs(); + + return response()->json([ + 'error' => false, + 'message' => 'Tạo bài viết thành công.', + 'data' => new PostResource($post->load('category')), + ], 201); + } + + public function show(int $id): JsonResponse + { + $post = Post::with('category')->find($id); + + if (!$post) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy bài viết.'], 404); + } + + return response()->json([ + 'error' => false, + 'data' => new PostResource($post), + ]); + } + + public function update(PostUpdateRequest $request, int $id): JsonResponse + { + $post = Post::find($id); + + if (!$post) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy bài viết.'], 404); + } + + $data = $request->validated(); + $slugSource = $data['slug'] ?: $data['title']; + $data['slug'] = GeneratesUniqueSlug::forModel(Post::class, $slugSource, $post->id); + $data['is_featured'] = (bool) ($data['is_featured'] ?? false); + $data['view_count'] = (int) ($data['view_count'] ?? $post->view_count); + + $post->update($data); + $this->revalidation->revalidateDocs(); + + return response()->json([ + 'error' => false, + 'message' => 'Cập nhật bài viết thành công.', + 'data' => new PostResource($post->fresh()->load('category')), + ]); + } + + public function destroy(int $id): JsonResponse + { + $post = Post::find($id); + + if (!$post) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy bài viết.'], 404); + } + + $post->delete(); + $this->revalidation->revalidateDocs(); + + return response()->json([ + 'error' => false, + 'message' => 'Xoá bài viết thành công.', + ]); + } + + public function syncWordpress(Request $request): JsonResponse + { + $validated = $request->validate([ + 'source_url' => 'nullable|url', + 'per_page' => 'nullable|integer|min:1|max:100', + 'max_pages' => 'nullable|integer|min:1|max:200', + ]); + + $result = $this->wordpressSync->sync( + $validated['source_url'] ?? null, + (int) ($validated['per_page'] ?? 20), + isset($validated['max_pages']) ? (int) $validated['max_pages'] : null, + ); + + $this->revalidation->revalidateDocs(); + + return response()->json([ + 'error' => false, + 'message' => "Đồng bộ hoàn tất. Tạo mới {$result['created']}, cập nhật {$result['updated']}, bỏ qua {$result['skipped']}.", + 'data' => $result, + ]); + } +} diff --git a/app/Http/Controllers/Api/Admin/EmailNotificationTemplateController.php b/app/Http/Controllers/Api/Admin/EmailNotificationTemplateController.php new file mode 100644 index 0000000..244f590 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/EmailNotificationTemplateController.php @@ -0,0 +1,127 @@ +orderBy('name') + ->get(); + + return response()->json(['error' => false, 'data' => $templates]); + } + + public function show(int $id): JsonResponse + { + $tpl = EmailNotificationTemplate::findOrFail($id); + $tpl['block_field_definitions'] = EmailNotificationTemplate::blockFieldDefinitions(); + return response()->json(['error' => false, 'data' => $tpl]); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'type' => 'required|string|in:invoice_notification,vm_credentials', + 'subject' => 'required|string|max:500', + 'header_color' => 'nullable|string|max:20', + 'header_title' => 'nullable|string|max:255', + 'blocks' => 'nullable|array', + 'is_active' => 'boolean', + ]); + + // Use default blocks if none provided + if (empty($validated['blocks'])) { + $validated['blocks'] = EmailNotificationTemplate::defaultBlocks($validated['type']); + } + + // If activating this template, deactivate others of the same type + if ($validated['is_active'] ?? true) { + EmailNotificationTemplate::where('type', $validated['type'])->update(['is_active' => false]); + } + + $tpl = EmailNotificationTemplate::create($validated); + + return response()->json(['error' => false, 'data' => $tpl], 201); + } + + public function update(Request $request, int $id): JsonResponse + { + $tpl = EmailNotificationTemplate::findOrFail($id); + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + 'subject' => 'sometimes|string|max:500', + 'header_color' => 'nullable|string|max:20', + 'header_title' => 'nullable|string|max:255', + 'blocks' => 'nullable|array', + 'is_active' => 'boolean', + ]); + + // If activating this template, deactivate others of the same type + if (!empty($validated['is_active'])) { + EmailNotificationTemplate::where('type', $tpl->type) + ->where('id', '!=', $id) + ->update(['is_active' => false]); + } + + $tpl->update($validated); + + return response()->json(['error' => false, 'data' => $tpl->fresh()]); + } + + public function destroy(int $id): JsonResponse + { + EmailNotificationTemplate::findOrFail($id)->delete(); + return response()->json(['error' => false, 'message' => 'Đã xoá template.']); + } + + public function preview(int $id): JsonResponse + { + $tpl = EmailNotificationTemplate::findOrFail($id); + $html = $this->renderer->renderPreview($tpl); + return response()->json(['error' => false, 'html' => $html]); + } + + public function fieldDefinitions(): JsonResponse + { + return response()->json([ + 'error' => false, + 'data' => [ + 'block_types' => $this->blockTypeDefinitions(), + 'block_field_definitions' => EmailNotificationTemplate::blockFieldDefinitions(), + 'default_blocks' => EmailNotificationTemplate::defaultBlocks(), + ], + ]); + } + + private function blockTypeDefinitions(): array + { + return [ + // Dùng cho cả 2 loại + ['type' => 'greeting', 'label' => 'Lời chào', 'description' => 'Chào hỏi khách hàng theo tên', 'for' => ['invoice_notification', 'vm_credentials']], + ['type' => 'custom_text', 'label' => 'Văn bản tùy chỉnh', 'description' => 'Nội dung HTML hoặc text tự do', 'for' => ['invoice_notification', 'vm_credentials']], + ['type' => 'footer', 'label' => 'Chữ ký kết thúc', 'description' => 'Trân trọng + tên công ty', 'for' => ['invoice_notification', 'vm_credentials']], + // invoice_notification + ['type' => 'invoice_info', 'label' => 'Thông tin hóa đơn', 'description' => 'Số HĐ, ngày tạo, tổng tiền, trạng thái...', 'for' => ['invoice_notification']], + ['type' => 'customer_info', 'label' => 'Thông tin khách hàng', 'description' => 'Tên, email, địa chỉ khách hàng', 'for' => ['invoice_notification']], + ['type' => 'order_items', 'label' => 'Chi tiết sản phẩm', 'description' => 'Bảng sản phẩm từ đơn hàng', 'for' => ['invoice_notification']], + ['type' => 'attachments', 'label' => 'File đính kèm', 'description' => 'Danh sách file đính kèm hóa đơn', 'for' => ['invoice_notification']], + // vm_credentials + ['type' => 'vm_credentials', 'label' => 'Thông tin truy cập VPS', 'description' => 'IP, username, port, password, OS', 'for' => ['vm_credentials']], + ['type' => 'product_info', 'label' => 'Thông tin dịch vụ', 'description' => 'Tên sản phẩm, chu kỳ, giá, hạn dùng', 'for' => ['vm_credentials']], + ]; + } +} diff --git a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php index b5bae11..d839fd0 100644 --- a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php +++ b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\Admin\EmailTemplate; use App\Http\Controllers\Controller; +use App\Models\Setting; use App\Models\Template; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -10,36 +11,53 @@ use Illuminate\Support\Facades\DB; class EmailTemplateController extends Controller { - public function index(): JsonResponse + public function index(Request $request): JsonResponse { - $templates = Template::orderBy('type_id')->orderBy('id')->get(); + $query = Template::with('invoiceTemplate:id,name,scenario') + ->orderBy('scenario') + ->orderBy('id'); + + if ($request->filled('scenario')) { + $query->where('scenario', $request->scenario); + } + + $templates = $query->get()->map(fn($t) => $this->format($t)); return response()->json(['error' => false, 'data' => $templates]); } public function show(int $id): JsonResponse { - $template = Template::findOrFail($id); - - return response()->json(['error' => false, 'data' => $template]); + $template = Template::with('invoiceTemplate:id,name,scenario')->findOrFail($id); + return response()->json(['error' => false, 'data' => $this->format($template)]); } public function store(Request $request): JsonResponse { $data = $request->validate([ - 'type_id' => 'required|integer|min:1', - 'name' => 'nullable|string|max:100', - 'subject' => 'nullable|string|max:100', - 'message' => 'nullable|string', - 'status' => 'boolean', + 'name' => 'nullable|string|max:255', + 'scenario' => 'nullable|string|in:vm_credentials,renewal_reminder,news_notification,invoice_pdf', + 'format' => 'nullable|in:html,json', + 'invoice_template_id' => 'nullable|integer|exists:template,id', + 'subject' => 'nullable|string|max:255', + 'message' => 'nullable|string', + 'status' => 'boolean', ]); - $template = DB::transaction(fn () => Template::create($data)); + $template = DB::transaction(fn() => Template::create([ + 'type_id' => 0, + 'name' => $data['name'] ?? null, + 'scenario' => $data['scenario'] ?? null, + 'format' => $data['format'] ?? 'html', + 'invoice_template_id' => $data['invoice_template_id'] ?? null, + 'subject' => $data['subject'] ?? null, + 'message' => $data['message'] ?? null, + 'status' => $data['status'] ?? 1, + ])); return response()->json([ - 'error' => false, - 'message' => 'Thêm email template thành công', - 'data' => $template, + 'error' => false, 'message' => 'Tạo template thành công.', + 'data' => $this->format($template), ], 201); } @@ -48,31 +66,90 @@ class EmailTemplateController extends Controller $template = Template::findOrFail($id); $data = $request->validate([ - 'type_id' => 'sometimes|integer|min:1', - 'name' => 'sometimes|nullable|string|max:100', - 'subject' => 'sometimes|nullable|string|max:100', - 'message' => 'sometimes|nullable|string', - 'status' => 'sometimes|boolean', + 'name' => 'sometimes|nullable|string|max:255', + 'scenario' => 'sometimes|nullable|string|in:vm_credentials,renewal_reminder,news_notification,invoice_pdf', + 'format' => 'sometimes|nullable|in:html,json', + 'invoice_template_id' => 'sometimes|nullable|integer|exists:template,id', + 'subject' => 'sometimes|nullable|string|max:255', + 'message' => 'sometimes|nullable|string', + 'status' => 'sometimes|boolean', ]); - DB::transaction(fn () => $template->update($data)); + DB::transaction(fn() => $template->update($data)); return response()->json([ - 'error' => false, - 'message' => 'Cập nhật email template thành công', - 'data' => $template->fresh(), + 'error' => false, 'message' => 'Cập nhật template thành công.', + 'data' => $this->format($template->fresh(['invoiceTemplate'])), ]); } public function destroy(int $id): JsonResponse { - $template = Template::findOrFail($id); + DB::transaction(fn() => Template::findOrFail($id)->delete()); + return response()->json(['error' => false, 'message' => 'Đã xoá template.']); + } - DB::transaction(fn () => $template->delete()); + // ── Email settings (scenario assignments) ───────────────────────────────── - return response()->json([ - 'error' => false, - 'message' => 'Đã xoá email template', + public function getEmailSettings(): JsonResponse + { + $setting = Setting::where('key', 'email_settings')->first(); + $value = $setting ? json_decode($setting->value, true) : []; + + return response()->json(['error' => false, 'data' => $value]); + } + + public function updateEmailSettings(Request $request): JsonResponse + { + $data = $request->validate([ + 'vm_template_id' => 'nullable|integer|exists:template,id', + 'renewal_template_id' => 'nullable|integer|exists:template,id', + 'news_template_id' => 'nullable|integer|exists:template,id', + 'invoice_template_id' => 'nullable|integer|exists:template,id', ]); + + $setting = Setting::firstOrNew(['key' => 'email_settings']); + $current = $setting->value ? json_decode($setting->value, true) : []; + $setting->value = json_encode(array_merge($current, $data)); + $setting->save(); + + return response()->json(['error' => false, 'message' => 'Đã lưu cài đặt email.', 'data' => json_decode($setting->value, true)]); + } + + // ── Invoice PDF preview ─────────────────────────────────────────────────── + + public function previewInvoiceJson(int $id): JsonResponse + { + $template = Template::findOrFail($id); + if ($template->scenario !== 'invoice_pdf' || $template->format !== 'json') { + return response()->json(['error' => true, 'message' => 'Template này không phải invoice JSON.'], 422); + } + + $config = json_decode($template->message ?? '{}', true); + // Return the config + blade HTML preview + $html = view('pdf.invoice-preview', compact('config'))->render(); + + return response()->json(['error' => false, 'html' => $html]); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private function format(Template $t): array + { + return [ + 'id' => $t->id, + 'type_id' => $t->type_id, + 'name' => $t->name, + 'scenario' => $t->scenario, + 'scenario_label' => Template::SCENARIOS[$t->scenario] ?? null, + 'format' => $t->format ?? 'html', + 'invoice_template_id' => $t->invoice_template_id, + 'invoice_template_name' => $t->invoiceTemplate?->name, + 'subject' => $t->subject, + 'message' => $t->message, + 'status' => $t->status, + 'created_at' => $t->created_at, + 'updated_at' => $t->updated_at, + ]; } } diff --git a/app/Http/Controllers/Api/Admin/OsManager/OsController.php b/app/Http/Controllers/Api/Admin/OsManager/OsController.php index 7ad38e9..033e7d8 100644 --- a/app/Http/Controllers/Api/Admin/OsManager/OsController.php +++ b/app/Http/Controllers/Api/Admin/OsManager/OsController.php @@ -13,17 +13,19 @@ class OsController extends Controller public function store(Request $request): JsonResponse { $validated = $request->validate([ - 'version' => 'required|string|max:100', - 'os_group_id' => 'required|integer|exists:os_group,id', - 'template_id' => 'nullable|string|max:255', - 'is_active' => 'boolean', + 'version' => 'required|string|max:100', + 'os_group_id' => 'required|integer|exists:os_group,id', + 'template_id' => 'nullable|string|max:255', + 'module_host_id' => 'nullable|integer|exists:module_host,id', + 'is_active' => 'boolean', ]); $os = Os::create([ - 'version' => $validated['version'], - 'os_group_id' => $validated['os_group_id'], - 'template_id' => $validated['template_id'] ?? null, - 'status' => ($validated['is_active'] ?? true) ? 1 : 0, + 'version' => $validated['version'], + 'os_group_id' => $validated['os_group_id'], + 'template_id' => $validated['template_id'] ?? null, + 'module_host_id' => $validated['module_host_id'] ?? null, + 'status' => ($validated['is_active'] ?? true) ? 1 : 0, ]); return response()->json([ @@ -42,17 +44,19 @@ class OsController extends Controller } $validated = $request->validate([ - 'version' => 'sometimes|required|string|max:100', - 'os_group_id' => 'sometimes|integer|exists:os_group,id', - 'template_id' => 'nullable|string|max:255', - 'is_active' => 'boolean', + 'version' => 'sometimes|required|string|max:100', + 'os_group_id' => 'sometimes|integer|exists:os_group,id', + 'template_id' => 'nullable|string|max:255', + 'module_host_id' => 'nullable|integer|exists:module_host,id', + 'is_active' => 'boolean', ]); $os->update([ - 'version' => $validated['version'] ?? $os->version, - 'os_group_id' => $validated['os_group_id'] ?? $os->os_group_id, - 'template_id' => array_key_exists('template_id', $validated) ? $validated['template_id'] : $os->template_id, - 'status' => isset($validated['is_active']) ? ($validated['is_active'] ? 1 : 0) : $os->status, + 'version' => $validated['version'] ?? $os->version, + 'os_group_id' => $validated['os_group_id'] ?? $os->os_group_id, + 'template_id' => array_key_exists('template_id', $validated) ? $validated['template_id'] : $os->template_id, + 'module_host_id' => array_key_exists('module_host_id', $validated) ? $validated['module_host_id'] : $os->module_host_id, + 'status' => isset($validated['is_active']) ? ($validated['is_active'] ? 1 : 0) : $os->status, ]); return response()->json([ @@ -79,12 +83,19 @@ class OsController extends Controller private function formatOs(Os $os): array { + $os->loadMissing('host.datacenter.vCenter'); + $host = $os->host; + return [ - 'id' => $os->id, - 'version' => $os->version, - 'template_id' => $os->template_id, - 'is_active' => (bool) $os->status, - 'os_group_id' => $os->os_group_id, + 'id' => $os->id, + 'version' => $os->version, + 'template_id' => $os->template_id, + 'is_active' => (bool) $os->status, + 'os_group_id' => $os->os_group_id, + 'module_host_id' => $os->module_host_id, + 'host_name' => $host ? $host->name : null, + 'datacenter_name' => $host?->datacenter?->name, + 'vcenter_name' => $host?->datacenter?->vCenter?->host, ]; } } diff --git a/app/Http/Controllers/Api/Admin/OsManager/OsGroupController.php b/app/Http/Controllers/Api/Admin/OsManager/OsGroupController.php index 09c3204..eec2267 100644 --- a/app/Http/Controllers/Api/Admin/OsManager/OsGroupController.php +++ b/app/Http/Controllers/Api/Admin/OsManager/OsGroupController.php @@ -16,7 +16,7 @@ class OsGroupController extends Controller public function index(Request $request): JsonResponse { $query = OsGroup::withCount('os') - ->with(['os' => fn($q) => $q->orderBy('version')]) + ->with(['os' => fn($q) => $q->with('host.datacenter.vCenter')->orderBy('version')]) ->when( $request->get('filter'), fn($q, $f) => $q->where('name', 'like', "%{$f}%") @@ -50,7 +50,7 @@ class OsGroupController extends Controller public function show(int $id): JsonResponse { $group = OsGroup::withCount('os') - ->with(['os' => fn($q) => $q->orderBy('version')]) + ->with(['os' => fn($q) => $q->with('host.datacenter.vCenter')->orderBy('version')]) ->find($id); if (! $group) { @@ -141,12 +141,18 @@ class OsGroupController extends Controller private function formatOs(\App\Models\Os $os): array { + $host = $os->relationLoaded('host') ? $os->host : null; + return [ - 'id' => $os->id, - 'version' => $os->version, - 'template_id' => $os->template_id, - 'is_active' => (bool) $os->status, - 'os_group_id' => $os->os_group_id, + 'id' => $os->id, + 'version' => $os->version, + 'template_id' => $os->template_id, + 'is_active' => (bool) $os->status, + 'os_group_id' => $os->os_group_id, + 'module_host_id' => $os->module_host_id, + 'host_name' => $host?->name, + 'datacenter_name' => $host?->datacenter?->name, + 'vcenter_name' => $host?->datacenter?->vCenter?->host, ]; } } diff --git a/app/Http/Controllers/Api/Admin/Product/ProductController.php b/app/Http/Controllers/Api/Admin/Product/ProductController.php index 4e2119d..2638cb4 100644 --- a/app/Http/Controllers/Api/Admin/Product/ProductController.php +++ b/app/Http/Controllers/Api/Admin/Product/ProductController.php @@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api\Admin\Product; use App\Http\Controllers\Controller; use App\Http\Resources\Api\Admin\ProductResource; +use App\Http\Services\NextjsRevalidationService; use App\Models\Product; +use App\Models\ProductPricings; use App\Repositories\Admin\Product\AdminProductRepositoryInterface; use App\Models\Os; use Illuminate\Http\JsonResponse; @@ -15,6 +17,7 @@ class ProductController extends Controller { public function __construct( private readonly AdminProductRepositoryInterface $products, + private readonly NextjsRevalidationService $revalidation, ) {} public function index(Request $request): JsonResponse @@ -67,10 +70,19 @@ class ProductController extends Controller 'ram' => 'nullable|numeric|min:0', 'disk' => 'nullable|numeric|min:0', 'bandwidth' => 'nullable|numeric|min:0', + 'billing_type' => 'nullable|string|in:recurring,onetime', + 'currency' => 'nullable|string|max:10', + 'price_monthly' => 'nullable|numeric|min:0', + 'price_quarterly' => 'nullable|numeric|min:0', + 'price_semiannually' => 'nullable|numeric|min:0', + 'price_annually' => 'nullable|numeric|min:0', + 'price_biennially' => 'nullable|numeric|min:0', + 'price_triennially' => 'nullable|numeric|min:0', + 'price_onetime' => 'nullable|numeric|min:0', ]); $productType = $validated['product_type'] ?? null; - + if (!$productType && !empty($validated['product_categories_id'])) { $category = \App\Models\ProductCategories::find($validated['product_categories_id']); $productType = $category?->type; @@ -89,6 +101,9 @@ class ProductController extends Controller 'bandwidth' => $validated['bandwidth'] ?? null, ]); + $this->savePricings($product, $validated); + $this->revalidation->revalidateProducts(); + return response()->json([ 'error' => false, 'message' => 'Tạo sản phẩm thành công.', @@ -116,6 +131,17 @@ class ProductController extends Controller 'bandwidth' => 'nullable|numeric|min:0', 'os_ids' => 'nullable|array', 'os_ids.*' => 'integer|exists:os,id', + 'module_host_id' => 'nullable|integer|exists:module_host,id', + 'module_datastore_id' => 'nullable|integer|exists:module_datastore,id', + 'billing_type' => 'nullable|string|in:recurring,onetime', + 'currency' => 'nullable|string|max:10', + 'price_monthly' => 'nullable|numeric|min:0', + 'price_quarterly' => 'nullable|numeric|min:0', + 'price_semiannually' => 'nullable|numeric|min:0', + 'price_annually' => 'nullable|numeric|min:0', + 'price_biennially' => 'nullable|numeric|min:0', + 'price_triennially' => 'nullable|numeric|min:0', + 'price_onetime' => 'nullable|numeric|min:0', ]); if (isset($validated['name']) && $validated['name'] !== $product->name) { @@ -138,13 +164,49 @@ class ProductController extends Controller $product->operatingSystems()->sync($osIds); } + $this->savePricings($product, $validated); + $this->revalidation->revalidateProducts(); + return response()->json([ 'error' => false, 'message' => 'Cập nhật sản phẩm thành công.', - 'data' => new ProductResource($product->fresh(['pricings', 'category'])), + 'data' => new ProductResource($product->fresh(['pricings', 'category', 'operatingSystems'])), ]); } + private function savePricings(Product $product, array $data): void + { + $currency = $data['currency'] ?? 'VND'; + $cycles = [ + 'monthly' => 'price_monthly', + 'quarterly' => 'price_quarterly', + 'semiannually' => 'price_semiannually', + 'annually' => 'price_annually', + 'biennially' => 'price_biennially', + 'triennially' => 'price_triennially', + 'onetime' => 'price_onetime', + ]; + + foreach ($cycles as $cycle => $field) { + if (!array_key_exists($field, $data)) { + continue; + } + + $price = $data[$field]; + + if ($price !== null && $price !== '') { + ProductPricings::updateOrCreate( + ['product_id' => $product->id, 'billing_cycle' => $cycle], + ['price' => $price, 'currency' => $currency, 'is_active' => true] + ); + } else { + ProductPricings::where('product_id', $product->id) + ->where('billing_cycle', $cycle) + ->delete(); + } + } + } + public function destroy(int $id): JsonResponse { $product = Product::find($id); @@ -154,6 +216,7 @@ class ProductController extends Controller } $product->delete(); + $this->revalidation->revalidateProducts(); return response()->json([ 'error' => false, @@ -248,15 +311,19 @@ class ProductController extends Controller public function getOsList(): JsonResponse { - $osList = Os::with('osGroup') + $osList = Os::with(['osGroup', 'host.datacenter.vCenter']) ->where('status', 1) ->get() ->map(fn($os) => [ - 'id' => $os->id, - 'name' => $os->name, - 'version' => $os->version, - 'os_group_name' => $os->osGroup?->name, - 'template_id' => $os->template_id, + 'id' => $os->id, + 'name' => $os->name, + 'version' => $os->version, + 'os_group_name' => $os->osGroup?->name, + 'template_id' => $os->template_id, + 'module_host_id' => $os->module_host_id, + 'host_name' => $os->host?->name, + 'datacenter_name' => $os->host?->datacenter?->name, + 'vcenter_name' => $os->host?->datacenter?->vCenter?->host, ]); return response()->json([ diff --git a/app/Http/Controllers/Api/Admin/Ticket/TicketController.php b/app/Http/Controllers/Api/Admin/Ticket/TicketController.php new file mode 100644 index 0000000..c3f82e7 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Ticket/TicketController.php @@ -0,0 +1,164 @@ +with(['customer']) + ->latest('id'); + + if ($request->filled('status')) { + $query->where('status', $request->input('status')); + } + + if ($request->filled('customer_id')) { + $query->where('customer_id', $request->input('customer_id')); + } + + if ($request->filled('search')) { + $query->where('subject', 'like', '%' . $request->input('search') . '%'); + } + + $paginated = $query->paginate($request->input('per_page', 15)); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => TicketResource::collection($paginated->items()), + 'total' => $paginated->total(), + 'per_page' => $paginated->perPage(), + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + ], + ]); + } + + /** + * Show a ticket with its replies and customer info. + */ + public function show(string $ticket_num) + { + $ticket = Ticket::query() + ->with(['reply.user', 'customer']) + ->where('ticket_num', $ticket_num) + ->first(); + + if (!$ticket) { + return response()->json([ + 'error' => true, + 'message' => 'Ticket không tồn tại.', + ], 404); + } + + return response()->json([ + 'error' => false, + 'data' => new TicketResource($ticket), + ]); + } + + /** + * Update ticket status. + */ + public function updateStatus(Request $request, string $ticket_num) + { + $request->validate([ + 'status' => ['required', 'string', 'in:open,pending,resolved,closed'], + ]); + + $ticket = Ticket::query()->where('ticket_num', $ticket_num)->first(); + + if (!$ticket) { + return response()->json([ + 'error' => true, + 'message' => 'Ticket không tồn tại.', + ], 404); + } + + $ticket->update(['status' => $request->input('status')]); + + return response()->json([ + 'error' => false, + 'message' => 'Trạng thái ticket đã được cập nhật.', + 'data' => new TicketResource($ticket->fresh()), + ]); + } + + /** + * Admin replies to a ticket. + */ + public function reply(Request $request, string $ticket_num) + { + $request->validate([ + 'message' => ['required', 'string', 'max:2000'], + ]); + + $admin = auth('admin')->user(); + + $ticket = Ticket::query()->where('ticket_num', $ticket_num)->first(); + + if (!$ticket) { + return response()->json([ + 'error' => true, + 'message' => 'Ticket không tồn tại.', + ], 404); + } + + $reply = $this->ticketReplyRepository->create([ + 'ticket_id' => $ticket->id, + 'user_id' => $admin->id, + 'message' => $request->input('message'), + 'customer_id' => $ticket->customer_id, + ]); + + Notification::notifyCustomer($ticket->customer, [ + 'type' => 'ticket_reply', + 'title' => 'Admin vừa phản hồi ticket của bạn', + 'body' => $ticket->ticket_num . ' - ' . $ticket->subject, + 'link' => '/tickets/' . $ticket->ticket_num, + 'data' => [ + 'ticket_num' => $ticket->ticket_num, + 'reply_id' => $reply->id, + ], + ]); + + // Update ticket status to pending after admin reply + if ($ticket->status === 'open') { + $ticket->update(['status' => 'pending']); + } + + $reply->load('user'); + + broadcast(new TicketReplyCreated( + ticketNum: $ticket->ticket_num, + customerId: $ticket->customer_id, + replyId: $reply->id, + )); + + return response()->json([ + 'error' => false, + 'message' => 'Phản hồi đã được gửi.', + 'data' => new TicketReplyResource($reply), + ], 201); + } +} diff --git a/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php b/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php index fa7dcee..b2a550c 100644 --- a/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php +++ b/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin\Transaction; use App\Enums\TransactionStatus; use App\Http\Controllers\Controller; +use App\Models\Notification; use App\Models\Log; use App\Models\Transaction; use Illuminate\Http\JsonResponse; @@ -112,6 +113,18 @@ class AdminTransactionController extends Controller 'customer_id' => $customer->id, ]); + Notification::notifyCustomer($customer, [ + 'type' => 'deposit_success', + 'title' => 'Nạp tiền thành công', + 'body' => 'Giao dịch ' . $transaction->public_code . ' đã được duyệt.', + 'link' => '/lich-su-giao-dich/' . $transaction->id, + 'data' => [ + 'transaction_uuid' => $transaction->uuid, + 'amount' => $transaction->amount, + 'public_code' => $transaction->public_code, + ], + ]); + DB::commit(); } catch (\Exception $e) { DB::rollBack(); diff --git a/app/Http/Controllers/Api/Client/Docs/PostCategoryController.php b/app/Http/Controllers/Api/Client/Docs/PostCategoryController.php new file mode 100644 index 0000000..af4e176 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Docs/PostCategoryController.php @@ -0,0 +1,29 @@ +where('status', 'active') + ->whereHas('posts', fn ($query) => $query->published()) + ->withCount([ + 'posts' => fn ($query) => $query->published(), + ]) + ->orderBy('sort_order') + ->orderBy('name') + ->get(); + + return response()->json([ + 'error' => false, + 'data' => PostCategoryResource::collection($categories), + ]); + } +} diff --git a/app/Http/Controllers/Api/Client/Docs/PostController.php b/app/Http/Controllers/Api/Client/Docs/PostController.php new file mode 100644 index 0000000..807e624 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Docs/PostController.php @@ -0,0 +1,100 @@ +get('per_page', 12), 50); + + $query = Post::query() + ->published() + ->with('category') + ->whereHas('category', fn ($q) => $q->where('status', 'active')) + ->when( + $request->filled('keyword'), + fn ($q) => $q->where(function ($subQuery) use ($request) { + $keyword = $request->string('keyword')->toString(); + $subQuery + ->where('title', 'like', "%{$keyword}%") + ->orWhere('excerpt', 'like', "%{$keyword}%") + ->orWhere('content', 'like', "%{$keyword}%"); + }) + ) + ->when( + $request->filled('category_slug'), + fn ($q) => $q->whereHas('category', fn ($subQuery) => $subQuery->where('slug', $request->string('category_slug')->toString())) + ) + ->orderByDesc('is_featured') + ->orderByDesc('published_at') + ->orderByDesc('id'); + + $paginator = $query->paginate($perPage); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => PostResource::collection($paginator->items()), + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + ], + ]); + } + + public function featured(): JsonResponse + { + $posts = Post::query() + ->published() + ->with('category') + ->where('is_featured', true) + ->whereHas('category', fn ($q) => $q->where('status', 'active')) + ->orderByDesc('published_at') + ->limit(6) + ->get(); + + return response()->json([ + 'error' => false, + 'data' => PostResource::collection($posts), + ]); + } + + public function show(string $slug): JsonResponse + { + $post = Post::query() + ->published() + ->with('category') + ->where('slug', $slug) + ->whereHas('category', fn ($q) => $q->where('status', 'active')) + ->first(); + + if (!$post) { + return response()->json(['error' => true, 'message' => 'Không tìm thấy bài viết.'], 404); + } + + $post->increment('view_count'); + $post->refresh(); + + $post->setRelation('related_posts', Post::query() + ->published() + ->with('category') + ->where('post_category_id', $post->post_category_id) + ->where('id', '!=', $post->id) + ->orderByDesc('published_at') + ->limit(4) + ->get()); + + return response()->json([ + 'error' => false, + 'data' => new PostResource($post), + ]); + } +} diff --git a/app/Http/Controllers/Api/Client/Service/ServiceController.php b/app/Http/Controllers/Api/Client/Service/ServiceController.php index bb11ba7..0e9c94e 100644 --- a/app/Http/Controllers/Api/Client/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Client/Service/ServiceController.php @@ -13,6 +13,7 @@ use App\Services\ActivityLogger; use App\Services\Api\ServiceClient\ServiceClientService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\DB; class ServiceController extends Controller { @@ -182,4 +183,123 @@ class ServiceController extends Controller ]); } + public function getAvailableOs(Request $request, string $uuid): JsonResponse + { + $customer = $request->user(); + + $service = Service::with([ + 'product.osVersions' => fn ($q) => $q->where('status', 1)->with('osGroup'), + ]) + ->where('uuid', $uuid) + ->where('customer_id', $customer->id) + ->first(); + + if (!$service) { + return response()->json(['success' => false, 'message' => 'Không tìm thấy dịch vụ'], 404); + } + + if (!in_array($service->status, ['active', 'suspended'], true)) { + return response()->json(['success' => false, 'message' => 'Dịch vụ không ở trạng thái hợp lệ để xem OS'], 422); + } + + if (!$service->product) { + return response()->json(['success' => false, 'message' => 'Không tìm thấy product của dịch vụ'], 404); + } + + $grouped = $service->product->osVersions + ->groupBy(fn ($os) => $os->osGroup?->id ?? 0) + ->map(fn ($items) => [ + 'group' => [ + 'id' => $items->first()->osGroup?->id, + 'name' => $items->first()->osGroup?->name, + 'thumbnail' => $items->first()->osGroup?->thumbnail, + ], + 'items' => $items->map(fn ($os) => [ + 'id' => $os->id, + 'version' => $os->version, + 'template_id' => $os->template_id, + ])->values(), + ]) + ->values(); + + return response()->json([ + 'success' => true, + 'data' => $grouped, + ]); + } + + public function rebuild(Request $request, string $uuid, vCenter $serviceVCenter): JsonResponse + { + $customer = $request->user(); + $payload = $request->validate([ + 'os_id' => ['required', 'integer', 'exists:os,id'], + 'password' => ['nullable', 'string', 'max:255'], + ]); + + $service = Service::with(['product.osVersions', 'vmware']) + ->where('uuid', $uuid) + ->where('customer_id', $customer->id) + ->first(); + + if (!$service) { + return response()->json(['success' => false, 'message' => 'Không tìm thấy dịch vụ'], 404); + } + + if ($service->status === 'suspended') { + return response()->json(['success' => false, 'message' => 'Dịch vụ đang bị tạm ngưng, không thể rebuild'], 422); + } + + if (!$service->isActive()) { + return response()->json(['success' => false, 'message' => 'Dịch vụ không ở trạng thái active'], 422); + } + + $allowedOsIds = $service->product?->osVersions() + ->where('status', 1) + ->pluck('os.id') + ->map('intval') + ->toArray() ?? []; + + if (!in_array((int) $payload['os_id'], $allowedOsIds, true)) { + return response()->json(['success' => false, 'message' => 'OS không hợp lệ cho sản phẩm hiện tại'], 422); + } + + $vm = $service->vmware; + if (!$vm) { + return response()->json(['success' => false, 'message' => 'Dịch vụ chưa được cấp phát VM'], 404); + } + + DB::table('reinstall_logs')->insert([ + 'product_id' => $vm->id, + 'product_type' => 4, + 'custome_id' => $customer->id, + 'os_id' => $payload['os_id'], + 'status' => 'pending', + 'initiator' => 'client', + 'note' => 'Client rebuild via service UUID: ' . $uuid, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $result = $serviceVCenter->reBuildVPS((string) $vm->name); + if (is_string($result) || ($result['error'] ?? false)) { + return response()->json([ + 'success' => false, + 'message' => is_string($result) ? $result : ($result['message'] ?? 'Không thể rebuild VM'), + ], 400); + } + + $this->activityLogger->log( + event: 'rebuild', + logName: 'VM', + description: "Customer {$customer->email} rebuild VM: {$vm->name} với OS ID: {$payload['os_id']} qua service UUID: {$uuid}", + subject: $vm, + newValues: ['os_id' => $payload['os_id'], 'service_uuid' => $uuid], + ); + + return response()->json([ + 'success' => true, + 'message' => 'Đã gửi yêu cầu rebuild VM', + ]); + } + } \ No newline at end of file diff --git a/app/Http/Controllers/Api/Client/Ticket/TicketController.php b/app/Http/Controllers/Api/Client/Ticket/TicketController.php new file mode 100644 index 0000000..0040b1d --- /dev/null +++ b/app/Http/Controllers/Api/Client/Ticket/TicketController.php @@ -0,0 +1,210 @@ +user(); + + $query = Ticket::query() + ->where('customer_id', $customer->id) + ->latest('id'); + + if ($request->filled('status')) { + $query->where('status', $request->input('status')); + } + + $paginated = $query->paginate(15); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => TicketResource::collection($paginated->items()), + 'total' => $paginated->total(), + 'per_page' => $paginated->perPage(), + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + ], + ]); + } + + /** + * Create a new ticket for the authenticated customer. + */ + public function store(Request $request) + { + $request->validate([ + 'subject' => ['required', 'string', 'min:5', 'max:200'], + 'description' => ['required', 'string', 'min:20'], + 'room' => ['nullable', 'string'], + 'data' => ['nullable', 'array'], + ]); + + $customer = auth('client')->user(); + + $ticketNum = 'TKT-' . now()->format('Ymd') . '-' . strtoupper(Str::random(5)); + + $ticket = $this->ticketRepository->create([ + 'ticket_num' => $ticketNum, + 'subject' => $request->input('subject'), + 'description' => $request->input('description'), + 'status' => 'open', + 'room' => $request->input('room'), + 'data' => $request->input('data') ? json_encode($request->input('data')) : null, + 'customer_id' => $customer->id, + ]); + + return response()->json([ + 'error' => false, + 'message' => 'Ticket đã được tạo thành công.', + 'data' => new TicketResource($ticket), + ], 201); + } + + /** + * Show a single ticket with its replies (must belong to customer). + */ + public function show(string $ticket_num) + { + $customer = auth('client')->user(); + + $ticket = Ticket::query() + ->with(['reply.user', 'customer']) + ->where('ticket_num', $ticket_num) + ->where('customer_id', $customer->id) + ->first(); + + if (!$ticket) { + return response()->json([ + 'error' => true, + 'message' => 'Ticket không tồn tại hoặc bạn không có quyền truy cập.', + ], 404); + } + + return response()->json([ + 'error' => false, + 'data' => new TicketResource($ticket), + ]); + } + + /** + * Add a reply to a ticket (customer side). + */ + public function reply(Request $request, string $ticket_num) + { + $request->validate([ + 'message' => ['required', 'string', 'max:2000'], + ]); + + $customer = auth('client')->user(); + + $ticket = Ticket::query() + ->where('ticket_num', $ticket_num) + ->where('customer_id', $customer->id) + ->first(); + + if (!$ticket) { + return response()->json([ + 'error' => true, + 'message' => 'Ticket không tồn tại hoặc bạn không có quyền truy cập.', + ], 404); + } + + if (in_array($ticket->status, ['closed', 'resolved'])) { + return response()->json([ + 'error' => true, + 'message' => 'Ticket đã đóng, không thể thêm phản hồi.', + ], 422); + } + + $reply = $this->ticketReplyRepository->create([ + 'ticket_id' => $ticket->id, + 'customer_id' => $customer->id, + 'message' => $request->input('message'), + 'user_id' => null, + ]); + + Notification::notifyAdmins([ + 'type' => 'ticket_reply', + 'title' => 'Có phản hồi mới từ khách hàng', + 'body' => $ticket->ticket_num . ' - ' . $ticket->subject, + 'link' => '/tickets/' . $ticket->ticket_num, + 'data' => [ + 'ticket_num' => $ticket->ticket_num, + 'reply_id' => $reply->id, + ], + ]); + + broadcast(new TicketReplyCreated( + ticketNum: $ticket->ticket_num, + customerId: $customer->id, + replyId: $reply->id, + )); + + // Update ticket status back to open (customer replied) + $ticket->update(['status' => 'open']); + + return response()->json([ + 'error' => false, + 'message' => 'Phản hồi đã được gửi.', + 'data' => new TicketReplyResource($reply), + ], 201); + } + + /** + * Customer closes their ticket. + */ + public function close(string $ticket_num) + { + $customer = auth('client')->user(); + + $ticket = Ticket::query() + ->where('ticket_num', $ticket_num) + ->where('customer_id', $customer->id) + ->first(); + + if (!$ticket) { + return response()->json([ + 'error' => true, + 'message' => 'Ticket không tồn tại hoặc bạn không có quyền truy cập.', + ], 404); + } + + if ($ticket->status === 'closed') { + return response()->json([ + 'error' => true, + 'message' => 'Ticket đã được đóng trước đó.', + ], 422); + } + + $ticket->update(['status' => 'closed']); + + return response()->json([ + 'error' => false, + 'message' => 'Ticket đã được đóng.', + 'data' => new TicketResource($ticket->fresh()), + ]); + } +} diff --git a/app/Http/Controllers/Api/NotificationController.php b/app/Http/Controllers/Api/NotificationController.php new file mode 100644 index 0000000..4ad5bac --- /dev/null +++ b/app/Http/Controllers/Api/NotificationController.php @@ -0,0 +1,78 @@ +resolveRecipient($recipientType); + + $notifications = Notification::query() + ->where('recipient_type', $recipientType) + ->where('recipient_id', $recipient->id) + ->orderByDesc('created_at') + ->limit(20) + ->get(); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => $notifications, + 'unread_count' => $notifications->where('is_read', false)->count(), + ], + ]); + } + + public function markRead(Request $request, string $recipientType, int $id): JsonResponse + { + $recipient = $this->resolveRecipient($recipientType); + + $notification = Notification::query() + ->where('recipient_type', $recipientType) + ->where('recipient_id', $recipient->id) + ->whereKey($id) + ->first(); + + if (!$notification) { + return response()->json(['error' => true, 'message' => 'Notification not found'], 404); + } + + $notification->update([ + 'is_read' => true, + 'read_at' => now(), + ]); + + return response()->json(['error' => false, 'data' => $notification]); + } + + public function markAllRead(Request $request, string $recipientType): JsonResponse + { + $recipient = $this->resolveRecipient($recipientType); + + Notification::query() + ->where('recipient_type', $recipientType) + ->where('recipient_id', $recipient->id) + ->where('is_read', false) + ->update([ + 'is_read' => true, + 'read_at' => now(), + ]); + + return response()->json(['error' => false, 'message' => 'Marked as read']); + } + + private function resolveRecipient(string $recipientType) + { + return match ($recipientType) { + 'admin' => auth('admin')->user(), + 'client' => auth('client')->user(), + default => abort(404), + }; + } +} diff --git a/app/Http/Controllers/Api/Service/OrderController.php b/app/Http/Controllers/Api/Service/OrderController.php index eb70e4c..c3fad65 100644 --- a/app/Http/Controllers/Api/Service/OrderController.php +++ b/app/Http/Controllers/Api/Service/OrderController.php @@ -14,10 +14,12 @@ use App\Http\Requests\Api\Order\PayOrderRequest; use App\Http\Requests\Api\Order\StoreOrderRequest; use App\Models\Invoice; use App\Models\InvoiceItem; - +use App\Models\ModuleVmware; use App\Models\Order; +use App\Models\OrderItem; use App\Models\Product; use App\Models\Service; +use App\Models\VirtualMachines; use App\Repositories\Api\Order\OrderRepository; use App\Services\ActivityLogger; use App\Services\Api\Order\OrderService; @@ -57,6 +59,35 @@ class OrderController extends Controller } } + /** + * GET /v1/orders/check-server-name?name=xxx + * Kiểm tra tên server đã tồn tại chưa (order_items, services, module_vmware, virtual_machines) + */ + public function checkServerName(Request $request): JsonResponse + { + $name = trim($request->query('name', '')); + + if ($name === '') { + return response()->json(['available' => false, 'message' => 'Tên server không được để trống.']); + } + + $exists = + OrderItem::where('server_name', $name)->exists() || + Service::where('server_name', $name)->exists() || + ModuleVmware::where('name', $name)->exists() || + ModuleVmware::where('hostname', $name)->exists() || + VirtualMachines::where('name', $name)->exists(); + + if ($exists) { + return response()->json([ + 'available' => false, + 'message' => 'Tên server này đã được sử dụng, vui lòng chọn tên khác.', + ]); + } + + return response()->json(['available' => true]); + } + /** * GET /v1/client/orders — customer's order list */ @@ -97,7 +128,7 @@ class OrderController extends Controller { try { $order = Order::query() - ->with(['invoice', 'orderItems.product', 'customer']) + ->with(['invoice', 'orderItems.product', 'orderItems.service', 'customer']) ->where('customer_id', $request->user()->id) ->where('id', $id) ->first(); @@ -227,6 +258,7 @@ class OrderController extends Controller { try { $order = Order::where('id', $id) + ->with(['invoice', 'orderItems.service']) ->where('customer_id', $request->user()->id) ->first(); @@ -238,6 +270,26 @@ class OrderController extends Controller return $this->error('Đơn hàng chưa được thanh toán.'); } + $paidAt = $order->invoice->paid_at ? Carbon::parse($order->invoice->paid_at) : null; + if (!$paidAt || $paidAt->diffInMinutes(now()) < 180) { + return $this->error('Vui lòng chờ khoảng 3 tiếng sau khi thanh toán mới có thể khởi tạo lại dịch vụ.'); + } + + $service = $order->orderItems->first()?->service; + if (!$service || (int) $service->customer_id !== (int) $request->user()->id) { + return $this->error('Không tìm thấy dịch vụ hợp lệ để khởi tạo lại.'); + } + + if ($service->status !== 'pending') { + return $this->error('Dịch vụ đã được khởi tạo hoặc không ở trạng thái chờ.'); + } + + if ($service->retry_requested_at) { + return $this->error('Dịch vụ đã được yêu cầu khởi tạo lại một lần. Vui lòng liên hệ kỹ thuật qua mailto:kythuat@tgs.com.vn'); + } + + $service->update(['retry_requested_at' => now()]); + RetryProvisioningOrchestrator::dispatch($order->id, $request->user()->id); $this->activityLogger->log( @@ -302,7 +354,6 @@ class OrderController extends Controller 'order.orderItems.product', 'order.vmWare.ipAddress', 'order.vmWare.os', - 'invoiceItems', 'customer', 'attachments', ]) @@ -691,6 +742,13 @@ class OrderController extends Controller 'server_name' => $item->server_name, 'os_id' => $item->os_id, 'price' => (float) $item->price, + 'service' => $item->service ? [ + 'id' => $item->service->id, + 'status' => $item->service->status, + 'status_text' => $item->service->status_label, + 'retry_requested_at' => $this->isoDate($item->service->retry_requested_at), + 'can_retry_provisioning' => $this->canRetryProvisioning($item->service), + ] : null, 'product' => $item->product ? [ 'id' => $item->product->id, 'uuid' => $item->product->uuid, @@ -737,6 +795,24 @@ class OrderController extends Controller ]; } + private function canRetryProvisioning(Service $service): bool + { + if ($service->status !== 'pending') { + return false; + } + + if ($service->retry_requested_at) { + return false; + } + + $paidAt = $service->invoice?->paid_at; + if (!$paidAt) { + return false; + } + + return Carbon::parse($paidAt)->diffInMinutes(now()) >= 180; + } + private function formatInvoiceDetail(Invoice $invoice): array { $order = $invoice->order; @@ -780,15 +856,15 @@ class OrderController extends Controller 'disk' => $firstItem->product?->disk, ], ] : null, - 'items' => ($invoice->relationLoaded('invoiceItems') ? $invoice->invoiceItems : collect())->map(fn($item) => [ - 'id' => $item->id, - 'type' => $item->type, - 'description' => $item->description, - 'months' => $item->months, - 'quantity' => $item->quantity, - 'unit_price' => (float) $item->unit_price, - 'total_price' => (float) $item->total_price, - ])->all(), + 'items' => $order ? $order->orderItems->map(fn($item) => [ + 'id' => $item->id, + 'product_name' => $item->product?->name, + 'server_name' => $item->server_name, + 'billing_cycle' => $item->billing_cycle, + 'price' => (float) $item->price, + 'months' => $item->months, + 'os_id' => $item->os_id, + ])->values()->all() : [], 'attachments' => ($invoice->relationLoaded('attachments') ? $invoice->attachments : collect())->map(fn($a) => [ 'id' => $a->id, 'filename' => $a->filename, diff --git a/app/Http/Controllers/Api/Service/ProductCategoryController.php b/app/Http/Controllers/Api/Service/ProductCategoryController.php index 8e4f8ac..8ff7e59 100644 --- a/app/Http/Controllers/Api/Service/ProductCategoryController.php +++ b/app/Http/Controllers/Api/Service/ProductCategoryController.php @@ -166,69 +166,7 @@ class ProductCategoryController extends Controller ]) ->get(); - $productResources = $products->map(function ($product) { - $billingCycleLabels = [ - 'monthly' => 'Hàng tháng', - 'quarterly' => 'Hàng quý', - 'semi_annually' => 'Nửa năm', - 'annually' => 'Hàng năm', - 'onetime' => 'Một lần', - ]; - - $periodUnits = [ - 'monthly' => 'month', - 'quarterly' => 'quarter', - 'semi_annually' => 'semi_year', - 'annually' => 'year', - 'onetime' => 'none', - ]; - - return [ - 'uuid' => $product->uuid, - 'name' => $product->name, - 'slug' => $product->slug, - 'short_description' => $product->short_description, - 'description' => $product->description, - 'thumbnail' => $product->thumbnail ? asset($product->thumbnail) : null, - 'product_type' => $product->product_type, - 'specs' => [ - 'cpu' => (int) $product->cpu, - 'ram' => (int) $product->ram, - 'disk' => (int) $product->disk, - 'bandwidth' => (int) $product->bandwidth, - 'gpu' => $product->gpu - ], - 'stock' => $product->stock, - 'price' => (int) $product->price, - 'price_sale' => $product->price_sale ? (int) $product->price_sale : null, - 'group' => $product->category->name ?? null, - 'os' => $product->osVersions->groupBy('os_group_id')->map(function ($versions) { - $group = $versions->first()->osGroup; - return [ - 'name' => $group?->name, - 'thumbnail' => $group?->thumbnail, - 'versions' => $versions->map(fn($os) => [ - 'version' => $os->version, - 'name' => $os->name, - ])->values(), - ]; - })->values(), - 'pricing' => $product->pricings->map(fn($p) => [ - 'id' => $p->id, - 'name' => $billingCycleLabels[$p->billing_cycle] ?? $p->billing_cycle, - 'price' => (int) $p->price, - 'price_sale' => $p->price_sale ? (int) $p->price_sale : null, - 'period' => $p->billing_cycle ? (int) match($p->billing_cycle) { - 'monthly' => 1, - 'quarterly' => 3, - 'semi_annually' => 6, - 'annually' => 12, - default => 1, - } : 1, - 'period_unit' => $periodUnits[$p->billing_cycle] ?? 'month', - ]), - ]; - }); + $productResources = $products->map(fn($product) => $this->formatProduct($product)); $categoryArray = $category->toArray(); $categoryArray['products'] = $productResources; @@ -332,69 +270,7 @@ class ProductCategoryController extends Controller ]) ->get(); - $billingCycleLabels = [ - 'monthly' => 'Hàng tháng', - 'quarterly' => 'Hàng quý', - 'semi_annually' => 'Nửa năm', - 'annually' => 'Hàng năm', - 'onetime' => 'Một lần', - ]; - - $periodUnits = [ - 'monthly' => 'month', - 'quarterly' => 'quarter', - 'semi_annually' => 'semi_year', - 'annually' => 'year', - 'onetime' => 'none', - ]; - - $productResources = $products->map(function ($product) use ($billingCycleLabels, $periodUnits) { - return [ - 'uuid' => $product->uuid, - 'name' => $product->name, - 'slug' => $product->slug, - 'short_description' => $product->short_description, - 'description' => $product->description, - 'thumbnail' => $product->thumbnail ? asset($product->thumbnail) : null, - 'product_type' => $product->product_type, - 'specs' => [ - 'cpu' => (int) $product->cpu, - 'ram' => (int) $product->ram, - 'disk' => (int) $product->disk, - 'bandwidth' => (int) $product->bandwidth, - 'gpu' => $product->gpu - ], - 'stock' => $product->stock, - 'price' => (int) $product->price, - 'price_sale' => $product->price_sale ? (int) $product->price_sale : null, - 'group' => $product->category->name ?? null, - 'os' => $product->osVersions->groupBy('os_group_id')->map(function ($versions) { - $group = $versions->first()->osGroup; - return [ - 'name' => $group?->name, - 'thumbnail' => $group?->thumbnail, - 'versions' => $versions->map(fn($os) => [ - 'version' => $os->version, - 'name' => $os->name, - ])->values(), - ]; - })->values(), - 'pricing' => $product->pricings->map(fn($p) => [ - 'id' => $p->id, - 'name' => $billingCycleLabels[$p->billing_cycle] ?? $p->billing_cycle, - 'price' => (int) $p->price, - 'price_sale' => $p->price_sale ? (int) $p->price_sale : null, - 'period' => $p->billing_cycle ? (int) match($p->billing_cycle) { - 'monthly' => 1, - 'quarterly' => 3, - 'semi_annually' => 6, - 'annually' => 12, - default => 1, - } : 1, - 'period_unit' => $periodUnits[$p->billing_cycle] ?? 'month', - ]), - ]; - }); + $productResources = $products->map(fn($product) => $this->formatProduct($product)); $childArray = $child->toArray(); $childArray['parent'] = $child->parent ? $child->parent->toArray() : null; @@ -406,4 +282,81 @@ class ProductCategoryController extends Controller 'data' => $childArray, ]); } + + private function formatProduct($product): array + { + $billingCycleLabels = [ + 'monthly' => 'Hàng tháng', + 'quarterly' => 'Hàng quý', + 'semiannually' => 'Nửa năm', + 'annually' => 'Hàng năm', + 'biennially' => '2 năm', + 'triennially' => '3 năm', + 'onetime' => 'Một lần', + ]; + + $periodMonths = [ + 'monthly' => 1, + 'quarterly' => 3, + 'semiannually' => 6, + 'annually' => 12, + 'biennially' => 24, + 'triennially' => 36, + 'onetime' => 0, + ]; + + $periodUnits = [ + 'monthly' => 'month', + 'quarterly' => 'quarter', + 'semiannually' => 'semi_year', + 'annually' => 'year', + 'biennially' => 'biennially', + 'triennially' => 'triennially', + 'onetime' => 'none', + ]; + + return [ + 'uuid' => $product->uuid, + 'name' => $product->name, + 'slug' => $product->slug, + 'short_description' => $product->short_description, + 'description' => $product->description, + 'thumbnail' => $product->thumbnail ? asset($product->thumbnail) : null, + 'product_type' => $product->product_type, + 'specs' => [ + 'cpu' => (int) $product->cpu, + 'ram' => (int) $product->ram, + 'disk' => (int) $product->disk, + 'bandwidth' => (int) $product->bandwidth, + 'gpu' => $product->gpu, + ], + 'stock' => $product->stock, + 'price' => (int) $product->price, + 'price_sale' => $product->price_sale ? (int) $product->price_sale : null, + 'group' => $product->category->name ?? null, + 'os' => $product->osVersions->groupBy('os_group_id')->map(function ($versions) { + $group = $versions->first()->osGroup; + return [ + 'name' => $group?->name, + 'thumbnail' => $group?->thumbnail, + 'versions' => $versions->map(fn($os) => [ + 'id' => $os->id, + 'version' => $os->version, + 'name' => $os->name, + ])->values(), + ]; + })->values(), + 'pricing' => $product->pricings + ->sortBy(fn($p) => $periodMonths[$p->billing_cycle] ?? 999) + ->values() + ->map(fn($p) => [ + 'id' => $p->id, + 'name' => $billingCycleLabels[$p->billing_cycle] ?? $p->billing_cycle, + 'price' => (int) $p->price, + 'price_sale' => $p->price_sale ? (int) $p->price_sale : null, + 'period' => $periodMonths[$p->billing_cycle] ?? 1, + 'period_unit' => $periodUnits[$p->billing_cycle] ?? 'month', + ]), + ]; + } } diff --git a/app/Http/Requests/Admin/PostCategoryStoreRequest.php b/app/Http/Requests/Admin/PostCategoryStoreRequest.php new file mode 100644 index 0000000..f2127c5 --- /dev/null +++ b/app/Http/Requests/Admin/PostCategoryStoreRequest.php @@ -0,0 +1,25 @@ + 'required|string|max:255', + 'slug' => 'nullable|string|max:255|unique:post_categories,slug', + 'description' => 'nullable|string', + 'icon' => 'nullable|string|max:255', + 'sort_order' => 'nullable|integer', + 'status' => 'required|in:active,inactive', + ]; + } +} diff --git a/app/Http/Requests/Admin/PostCategoryUpdateRequest.php b/app/Http/Requests/Admin/PostCategoryUpdateRequest.php new file mode 100644 index 0000000..cd6d149 --- /dev/null +++ b/app/Http/Requests/Admin/PostCategoryUpdateRequest.php @@ -0,0 +1,28 @@ +route('id'); + + return [ + 'name' => 'required|string|max:255', + 'slug' => ['nullable', 'string', 'max:255', Rule::unique('post_categories', 'slug')->ignore($id)], + 'description' => 'nullable|string', + 'icon' => 'nullable|string|max:255', + 'sort_order' => 'nullable|integer', + 'status' => 'required|in:active,inactive', + ]; + } +} diff --git a/app/Http/Requests/Admin/PostStoreRequest.php b/app/Http/Requests/Admin/PostStoreRequest.php new file mode 100644 index 0000000..f41a454 --- /dev/null +++ b/app/Http/Requests/Admin/PostStoreRequest.php @@ -0,0 +1,32 @@ + 'required|integer|exists:post_categories,id', + 'title' => 'required|string|max:255', + 'slug' => 'nullable|string|max:255|unique:posts,slug', + 'excerpt' => 'nullable|string', + 'content' => 'required|string', + 'thumbnail' => 'nullable|string|max:255', + 'status' => 'required|in:draft,published,hidden', + 'is_featured' => 'nullable|boolean', + 'view_count' => 'nullable|integer|min:0', + 'reading_time' => 'nullable|integer|min:1', + 'meta_title' => 'nullable|string|max:255', + 'meta_description' => 'nullable|string', + 'published_at' => 'nullable|date', + ]; + } +} diff --git a/app/Http/Requests/Admin/PostUpdateRequest.php b/app/Http/Requests/Admin/PostUpdateRequest.php new file mode 100644 index 0000000..0c9cbcb --- /dev/null +++ b/app/Http/Requests/Admin/PostUpdateRequest.php @@ -0,0 +1,35 @@ +route('id'); + + return [ + 'post_category_id' => 'required|integer|exists:post_categories,id', + 'title' => 'required|string|max:255', + 'slug' => ['nullable', 'string', 'max:255', Rule::unique('posts', 'slug')->ignore($id)], + 'excerpt' => 'nullable|string', + 'content' => 'required|string', + 'thumbnail' => 'nullable|string|max:255', + 'status' => 'required|in:draft,published,hidden', + 'is_featured' => 'nullable|boolean', + 'view_count' => 'nullable|integer|min:0', + 'reading_time' => 'nullable|integer|min:1', + 'meta_title' => 'nullable|string|max:255', + 'meta_description' => 'nullable|string', + 'published_at' => 'nullable|date', + ]; + } +} diff --git a/app/Http/Resources/Api/Admin/ProductResource.php b/app/Http/Resources/Api/Admin/ProductResource.php index 316d6eb..b9d82cc 100644 --- a/app/Http/Resources/Api/Admin/ProductResource.php +++ b/app/Http/Resources/Api/Admin/ProductResource.php @@ -32,9 +32,24 @@ class ProductResource extends JsonResource 'bandwidth' => $this->bandwidth, 'setup_fee' => $this->setup_fee, 'min_price' => $minPrice, + 'billing_type' => $this->whenLoaded('pricings', function () { + $hasOnetime = $this->pricings->where('billing_cycle', 'onetime')->isNotEmpty(); + $hasRecurring = $this->pricings->where('billing_cycle', '!=', 'onetime')->isNotEmpty(); + return ($hasOnetime && !$hasRecurring) ? 'onetime' : 'recurring'; + }), + 'currency' => $this->whenLoaded('pricings', fn () => $this->pricings->first()?->currency ?? 'VND'), + 'price_monthly' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'monthly'))->price), + 'price_quarterly' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'quarterly'))->price), + 'price_semiannually' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'semiannually'))->price), + 'price_annually' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'annually'))->price), + 'price_biennially' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'biennially'))->price), + 'price_triennially' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'triennially'))->price), + 'price_onetime' => $this->whenLoaded('pricings', fn () => optional($this->pricings->firstWhere('billing_cycle', 'onetime'))->price), 'is_active' => $this->is_active, 'created_at' => $this->created_at, 'os_ids' => $osIds, + 'module_host_id' => $this->module_host_id, + 'module_datastore_id' => $this->module_datastore_id, ]; } diff --git a/app/Http/Resources/Api/PostCategoryResource.php b/app/Http/Resources/Api/PostCategoryResource.php new file mode 100644 index 0000000..5475c86 --- /dev/null +++ b/app/Http/Resources/Api/PostCategoryResource.php @@ -0,0 +1,25 @@ + $this->id, + 'name' => $this->name, + 'slug' => $this->slug, + 'description' => $this->description, + 'icon' => $this->icon, + 'sort_order' => $this->sort_order, + 'status' => $this->status, + 'posts_count' => $this->whenCounted('posts'), + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/app/Http/Resources/Api/PostResource.php b/app/Http/Resources/Api/PostResource.php new file mode 100644 index 0000000..d8288dd --- /dev/null +++ b/app/Http/Resources/Api/PostResource.php @@ -0,0 +1,48 @@ + $this->id, + 'post_category_id' => $this->post_category_id, + 'title' => $this->title, + 'slug' => $this->slug, + 'excerpt' => $this->excerpt, + 'content' => $this->content, + 'thumbnail' => $this->thumbnail, + 'status' => $this->status, + 'is_featured' => (bool) $this->is_featured, + 'view_count' => (int) $this->view_count, + 'reading_time' => $this->reading_time, + 'meta_title' => $this->meta_title, + 'meta_description' => $this->meta_description, + 'published_at' => $this->published_at?->toISOString(), + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + 'category' => $this->whenLoaded('category', fn () => new PostCategoryResource($this->category)), + 'related_posts' => $this->when(isset($this->related_posts), fn () => $this->related_posts->map(fn ($post) => [ + 'id' => $post->id, + 'post_category_id' => $post->post_category_id, + 'title' => $post->title, + 'slug' => $post->slug, + 'excerpt' => $post->excerpt, + 'thumbnail' => $post->thumbnail, + 'status' => $post->status, + 'is_featured' => (bool) $post->is_featured, + 'view_count' => (int) $post->view_count, + 'reading_time' => $post->reading_time, + 'published_at' => $post->published_at?->toISOString(), + 'category' => $post->relationLoaded('category') && $post->category + ? new PostCategoryResource($post->category) + : null, + ])->values()), + ]; + } +} diff --git a/app/Http/Resources/Api/Service/ServiceResource.php b/app/Http/Resources/Api/Service/ServiceResource.php index f1dc366..d32dbae 100644 --- a/app/Http/Resources/Api/Service/ServiceResource.php +++ b/app/Http/Resources/Api/Service/ServiceResource.php @@ -15,19 +15,33 @@ class ServiceResource extends JsonResource public function toArray(Request $request): array { $billingCycleLabels = [ - 'monthly' => 'Hàng tháng', - 'quarterly' => 'Hàng quý', - 'semi_annually' => 'Nửa năm', - 'annually' => 'Hàng năm', - 'onetime' => 'Một lần', + 'monthly' => 'Hàng tháng', + 'quarterly' => 'Hàng quý', + 'semiannually' => 'Nửa năm', + 'annually' => 'Hàng năm', + 'biennially' => '2 năm', + 'triennially' => '3 năm', + 'onetime' => 'Một lần', ]; $periodUnits = [ - 'monthly' => 'month', - 'quarterly' => 'quarter', - 'semi_annually' => 'semi_year', - 'annually' => 'year', - 'onetime' => 'none', + 'monthly' => 'month', + 'quarterly' => 'quarter', + 'semiannually' => 'semi_year', + 'annually' => 'year', + 'biennially' => 'biennially', + 'triennially' => 'triennially', + 'onetime' => 'none', + ]; + + $periodMonths = [ + 'monthly' => 1, + 'quarterly' => 3, + 'semiannually' => 6, + 'annually' => 12, + 'biennially' => 24, + 'triennially' => 36, + 'onetime' => 0, ]; return [ @@ -62,20 +76,17 @@ class ServiceResource extends JsonResource ])->values(), ]; })->values(), - 'pricing' => $this->pricings->map(fn($p) => [ - 'id' => $p->id, - 'name' => $billingCycleLabels[$p->billing_cycle] ?? $p->billing_cycle, - 'price' => (int) $p->price, - 'price_sale' => $p->price_sale ? (int) $p->price_sale : null, - 'period' => $p->billing_cycle ? (int) match($p->billing_cycle) { - 'monthly' => 1, - 'quarterly' => 3, - 'semi_annually' => 6, - 'annually' => 12, - default => 1, - } : 1, - 'period_unit' => $periodUnits[$p->billing_cycle] ?? 'month', - ]), + 'pricing' => $this->pricings + ->sortBy(fn($p) => $periodMonths[$p->billing_cycle] ?? 999) + ->values() + ->map(fn($p) => [ + 'id' => $p->id, + 'name' => $billingCycleLabels[$p->billing_cycle] ?? $p->billing_cycle, + 'price' => (int) $p->price, + 'price_sale' => $p->price_sale ? (int) $p->price_sale : null, + 'period' => $periodMonths[$p->billing_cycle] ?? 1, + 'period_unit' => $periodUnits[$p->billing_cycle] ?? 'month', + ]), ]; } diff --git a/app/Http/Resources/Api/Ticket/TicketReplyResource.php b/app/Http/Resources/Api/Ticket/TicketReplyResource.php new file mode 100644 index 0000000..7b53f79 --- /dev/null +++ b/app/Http/Resources/Api/Ticket/TicketReplyResource.php @@ -0,0 +1,29 @@ + $this->id, + 'ticket_id' => $this->ticket_id, + 'message' => $this->message, + 'sender_type' => $this->user_id ? 'staff' : 'client', + 'sender_name' => $this->when( + $this->user_id, + fn() => optional($this->user)->username ?? optional($this->user)->name ?? 'Admin', + 'Khách hàng' + ), + 'sender_role' => $this->user_id ? 'Nhân viên hỗ trợ' : 'Khách hàng', + 'user_id' => $this->user_id, + 'customer_id' => $this->customer_id, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/Api/Ticket/TicketResource.php b/app/Http/Resources/Api/Ticket/TicketResource.php new file mode 100644 index 0000000..c76bf33 --- /dev/null +++ b/app/Http/Resources/Api/Ticket/TicketResource.php @@ -0,0 +1,31 @@ + $this->id, + 'ticket_num' => $this->ticket_num, + 'subject' => $this->subject, + 'description' => $this->description, + 'status' => $this->status, + 'room' => $this->room, + 'data' => $this->data ? (is_string($this->data) ? json_decode($this->data, true) : $this->data) : null, + 'customer_id' => $this->customer_id, + 'customer' => $this->whenLoaded('customer', fn() => [ + 'id' => $this->customer->id, + 'fullname' => $this->customer->fullname, + 'email' => $this->customer->email, + ]), + 'replies' => TicketReplyResource::collection($this->whenLoaded('reply')), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Services/DocsWordpressSyncService.php b/app/Http/Services/DocsWordpressSyncService.php new file mode 100644 index 0000000..4d2b017 --- /dev/null +++ b/app/Http/Services/DocsWordpressSyncService.php @@ -0,0 +1,154 @@ + max(1, $maxPages)) { + break; + } + + $response = Http::timeout(20) + ->retry(2, 300) + ->acceptJson() + ->get($url, [ + 'per_page' => min(max($perPage, 1), 100), + 'page' => $page, + '_embed' => 1, + 'status' => 'publish', + ]); + + $response->throw(); + + /** @var array> $items */ + $items = $response->json() ?? []; + + if (empty($items)) { + break; + } + + foreach ($items as $item) { + $total++; + $result = $this->syncItem($item); + + match ($result) { + 'created' => $created++, + 'updated' => $updated++, + default => $skipped++, + }; + } + + if (count($items) < $perPage) { + break; + } + + $page++; + } + + return compact('created', 'updated', 'skipped', 'total'); + } + + private function syncItem(array $item): string + { + $title = trim((string) data_get($item, 'title.rendered', '')); + $content = (string) data_get($item, 'content.rendered', ''); + $slug = trim((string) data_get($item, 'slug', '')); + + if ($title === '' || $content === '') { + return 'skipped'; + } + + $category = $this->resolveCategory($item); + $existing = Post::withTrashed()->where('slug', $slug)->first(); + $thumbnail = $this->resolveFeaturedImage($item); + $excerpt = trim(strip_tags((string) data_get($item, 'excerpt.rendered', ''))); + $publishedAt = data_get($item, 'date_gmt') ?: data_get($item, 'date'); + + $payload = [ + 'post_category_id' => $category->id, + 'title' => html_entity_decode(strip_tags($title), ENT_QUOTES | ENT_HTML5, 'UTF-8'), + 'excerpt' => $excerpt !== '' ? html_entity_decode($excerpt, ENT_QUOTES | ENT_HTML5, 'UTF-8') : null, + 'content' => $content, + 'thumbnail' => $thumbnail, + 'status' => 'published', + 'is_featured' => false, + 'reading_time' => $this->estimateReadingTime($content), + 'meta_title' => html_entity_decode(strip_tags($title), ENT_QUOTES | ENT_HTML5, 'UTF-8'), + 'meta_description' => $excerpt !== '' ? html_entity_decode($excerpt, ENT_QUOTES | ENT_HTML5, 'UTF-8') : null, + 'published_at' => $publishedAt ? Carbon::parse($publishedAt) : now(), + ]; + + if ($existing) { + $existing->restore(); + $existing->update($payload); + return 'updated'; + } + + Post::create($payload + [ + 'slug' => GeneratesUniqueSlug::forModel(Post::class, $slug !== '' ? $slug : $title), + 'view_count' => 0, + ]); + + return 'created'; + } + + private function resolveCategory(array $item): PostCategory + { + $termGroups = (array) data_get($item, '_embedded.wp:term', []); + $terms = is_array($termGroups[0] ?? null) ? $termGroups[0] : []; + $firstTerm = Arr::first($terms); + $categoryName = trim((string) data_get($firstTerm, 'name', 'Imported Docs')); + $categorySlug = trim((string) data_get($firstTerm, 'slug', 'imported-docs')); + + return PostCategory::firstOrCreate( + ['slug' => $categorySlug !== '' ? $categorySlug : 'imported-docs'], + [ + 'name' => $categoryName !== '' ? $categoryName : 'Imported Docs', + 'description' => 'Danh mục đồng bộ từ WordPress Docs.', + 'status' => 'active', + 'sort_order' => 999, + ] + ); + } + + private function resolveFeaturedImage(array $item): ?string + { + return data_get($item, '_embedded.wp:featuredmedia.0.source_url') + ?: data_get($item, '_embedded.wp:featuredmedia.0.media_details.sizes.full.source_url') + ?: null; + } + + private function estimateReadingTime(string $html): int + { + $wordCount = str_word_count(Str::of(strip_tags($html))->squish()->value()); + + return max(1, (int) ceil($wordCount / 200)); + } +} diff --git a/app/Http/Services/NextjsRevalidationService.php b/app/Http/Services/NextjsRevalidationService.php new file mode 100644 index 0000000..59fa499 --- /dev/null +++ b/app/Http/Services/NextjsRevalidationService.php @@ -0,0 +1,37 @@ +dispatch(['products']); + } + + public function revalidateDocs(): void + { + $this->dispatch(['docs']); + } + + private function dispatch(array $tags): void + { + $url = rtrim(config('services.nextjs.url'), '/') . '/api/revalidate'; + $secret = config('services.nextjs.revalidate_secret'); + + if (!$url || !$secret) { + return; + } + + try { + Http::withHeaders(['x-revalidate-secret' => $secret]) + ->timeout(5) + ->post($url, ['tags' => $tags]); + } catch (\Throwable $e) { + Log::warning('Next.js revalidation failed: ' . $e->getMessage()); + } + } +} diff --git a/app/Jobs/Mail/SendInformationVirtualMachine.php b/app/Jobs/Mail/SendInformationVirtualMachine.php index ebe24c5..b1bdf6a 100644 --- a/app/Jobs/Mail/SendInformationVirtualMachine.php +++ b/app/Jobs/Mail/SendInformationVirtualMachine.php @@ -4,7 +4,10 @@ namespace App\Jobs\Mail; use App\Http\Services\Telegram; use App\Mail\CreateVirtualMachine; +use App\Mail\VmCredentialsMail; +use App\Models\EmailNotificationTemplate; use App\Models\Invoice; +use App\Services\EmailNotificationTemplateRenderer; use App\Services\InvoicePdfService; use Carbon\Carbon; use Illuminate\Bus\Queueable; @@ -34,7 +37,8 @@ class SendInformationVirtualMachine implements ShouldQueue { try { // Generate PDF inside the job — never pass binary blobs through queue payload - $invoicePdf = null; + $invoice = null; + $invoicePdf = null; $invoicePdfFileName = null; if ($this->invoiceId) { @@ -55,7 +59,17 @@ class SendInformationVirtualMachine implements ShouldQueue } } - $email = new CreateVirtualMachine(2, $this->data, $invoicePdf, $invoicePdfFileName); + // Try block-based DB template first + $dbTemplate = EmailNotificationTemplate::where('type', 'vm_credentials') + ->where('is_active', true) + ->first(); + + if ($dbTemplate) { + $email = new VmCredentialsMail($dbTemplate, $this->data, $invoice, $invoicePdf, $invoicePdfFileName); + } else { + // Fallback: legacy Template model (ID=2) + $email = new CreateVirtualMachine(2, $this->data, $invoicePdf, $invoicePdfFileName); + } Mail::to($this->customer->email)->send($email); diff --git a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php index bf293e0..de83283 100644 --- a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php +++ b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php @@ -7,6 +7,7 @@ use App\Http\Services\vCenter; use App\Jobs\Mail\Customer\VMCreationFailedJob; use App\Jobs\VirtualMachine\Provision\ChangePasswordViaVsphereJob; use App\Services\VmProvisionService; +use App\Models\ModuleHost; use App\Models\OrderItem; use App\Models\Os; use App\Models\ModuleVmware; @@ -62,7 +63,7 @@ class CreateVirtualMachineJob implements ShouldQueue return false; } - $osModel = Os::with('osGroup.host.datacenter.vcenter')->find($this->item->os_id); + $osModel = Os::with(['host.datacenter.vCenter', 'osGroup.host.datacenter.vCenter'])->find($this->item->os_id); if (!$osModel) { $errorMessage = "Không tìm thấy OS id={$this->item->os_id} trong OrderItem #{$this->item->id}. os_id không hợp lệ."; @@ -81,20 +82,59 @@ class CreateVirtualMachineJob implements ShouldQueue } $template = $osModel->template_id; - $host = $osModel->osGroup?->host; // từ os_group.module_host_id + + // Ưu tiên 1: host gán trực tiếp trên OS + $host = $osModel->host; $datacenter = $host?->datacenter; - $vcenter = $datacenter?->vcenter; + $vcenter = $datacenter?->vCenter; + + // Ưu tiên 2: host từ osGroup (cũ) + if (!$host || !$datacenter || !$vcenter) { + $groupHost = $osModel->osGroup?->host; + $host = $host ?? $groupHost; + $datacenter = $datacenter ?? $groupHost?->datacenter; + $vcenter = $vcenter ?? $groupHost?->datacenter?->vCenter; + + if ($groupHost) { + Log::info('CreateVirtualMachineJob: dùng host từ osGroup làm fallback', [ + 'os_id' => $osModel->id, + 'os_group_id' => $osModel->os_group_id, + 'host_id' => $groupHost->id, + ]); + } + } + + // Ưu tiên 3: host từ sản phẩm (cấu hình tab Provisioning) + if ((!$host || !$datacenter || !$vcenter) && $product->module_host_id) { + $productHost = ModuleHost::with('datacenter.vCenter')->find($product->module_host_id); + if ($productHost) { + $host = $host ?? $productHost; + $datacenter = $datacenter ?? $productHost->datacenter; + $vcenter = $vcenter ?? $productHost->datacenter?->vCenter; + + Log::info('CreateVirtualMachineJob: dùng host từ sản phẩm làm fallback', [ + 'product_id' => $product->id, + 'host_id' => $productHost->id, + 'datacenter_id' => $productHost->datacenter?->id, + ]); + } + } if (!$vcenter || !$host || !$datacenter) { - $errorMessage = "Thiếu thông tin host/datacenter/vcenter từ sản phẩm ID #{$product->id}"; + $errorMessage = "Thiếu thông tin host/datacenter/vcenter từ sản phẩm ID #{$product->id}. " + . "Vui lòng cấu hình vCenter trong tab Provisioning của sản phẩm, " + . "hoặc gán host cho OS group của OS được chọn."; Log::error($errorMessage); $telegram->SendErrorMessage([ 'error' => $errorMessage, 'product_id' => $product->id, 'order_item_id' => $this->item->id, + 'os_id' => $this->item->os_id, + 'os_group_name' => $osModel->osGroup?->name, + 'product_module_host_id' => $product->module_host_id, 'host_id' => $host?->id, 'datacenter_id' => $datacenter?->id, - 'vcenter_id' => $vcenter?->id + 'vcenter_id' => $vcenter?->id, ], 'CreateVirtualMachineJob - Missing Infrastructure'); if (!$this->isRetry) { @@ -171,7 +211,7 @@ class CreateVirtualMachineJob implements ShouldQueue ]); Log::info('CreateVirtualMachineJob - Result: ' . json_encode($result)); if (is_array($result) && ($result['error'] ?? false)) { - $errorMessage = 'Tạo máy ảo thất bại: ' . json_encode($result); + $errorMessage = $this->translateVcenterError($result); Log::warning($errorMessage); $telegram->SendErrorMessage([ 'error' => $result['error'] ?? 'Unknown error', @@ -490,6 +530,32 @@ class CreateVirtualMachineJob implements ShouldQueue * Gửi email thông báo VM đã khởi tạo thành công cho khách hàng. * Gọi ngay sau khi tạo module_vmware — cho cả Linux lẫn Windows. */ + /** + * Dịch error từ vCenter API sang tiếng Việt dễ hiểu. + */ + private function translateVcenterError(array $result): string + { + $data = $result['data'] ?? []; + $errorType = is_array($data) ? ($data['error_type'] ?? '') : ''; + $serverName = $this->item->server_name ?? ''; + + $map = [ + 'ALREADY_EXISTS' => "Tên máy chủ \"{$serverName}\" đã tồn tại trong hệ thống. Vui lòng liên hệ hỗ trợ để được xử lý hoặc chọn tên máy chủ khác.", + 'NOT_FOUND' => 'Không tìm thấy template hoặc tài nguyên trên vCenter. Vui lòng liên hệ hỗ trợ.', + 'RESOURCE_INACCESSIBLE' => 'Tài nguyên vCenter không khả dụng. Vui lòng thử lại sau hoặc liên hệ hỗ trợ.', + 'INVALID_ARGUMENT' => 'Thông tin cấu hình máy chủ không hợp lệ. Vui lòng liên hệ hỗ trợ.', + 'UNABLE_TO_ALLOCATE_RESOURCE' => 'Không đủ tài nguyên để khởi tạo máy chủ. Vui lòng liên hệ hỗ trợ.', + 'TIMED_OUT' => 'Yêu cầu khởi tạo máy chủ hết thời gian chờ. Vui lòng thử lại sau.', + ]; + + if ($errorType && isset($map[$errorType])) { + return $map[$errorType]; + } + + // Fallback: không để lộ JSON thô ra email khách hàng + return "Không thể khởi tạo máy chủ ảo lúc này. Chúng tôi đã ghi nhận và sẽ xử lý trong thời gian sớm nhất. Vui lòng liên hệ hỗ trợ nếu cần."; + } + private function getBillingCycleLabel(?int $months): string { return match ($months) { diff --git a/app/Jobs/VirtualMachine/Provision/RetryProvisioningOrchestrator.php b/app/Jobs/VirtualMachine/Provision/RetryProvisioningOrchestrator.php index 35aca11..98e1540 100644 --- a/app/Jobs/VirtualMachine/Provision/RetryProvisioningOrchestrator.php +++ b/app/Jobs/VirtualMachine/Provision/RetryProvisioningOrchestrator.php @@ -205,17 +205,28 @@ class RetryProvisioningOrchestrator implements ShouldQueue /** * Resolve vcenter from DB chain: - * Path 1: order_items.os_id → os → osGroup → host → datacenter → vcenter - * Path 2: invoices → invoice_items → product_id → os_product → os → osGroup → host → datacenter → vcenter - * Path 3: module_vmware with same order_id (partial provisioning) + * Path 1a: order_items.os_id → os.host → datacenter → vcenter (ưu tiên: host gán trực tiếp trên OS) + * Path 1b: order_items.os_id → os → osGroup → host → datacenter → vcenter + * Path 2a: invoices → product → os_product → os.host → datacenter → vcenter + * Path 2b: invoices → product → os_product → os → osGroup → host → datacenter → vcenter + * Path 3: module_vmware with same order_id (partial provisioning) */ private function resolveVcenter(OrderItem $item, Telegram $telegram): ?VcenterModel { if ($item->os_id) { - $os = Os::with('osGroup.host.datacenter.vCenter')->find($item->os_id); + $os = Os::with(['host.datacenter.vCenter', 'osGroup.host.datacenter.vCenter'])->find($item->os_id); + + // Path 1a: host gán trực tiếp trên OS + $vcenter = $os?->host?->datacenter?->vCenter; + if ($vcenter) { + Log::info('resolveVcenter: found via os.host chain', ['os_id' => $item->os_id, 'vcenter_id' => $vcenter->id]); + return $vcenter; + } + + // Path 1b: host qua osGroup (cũ) $vcenter = $os?->osGroup?->host?->datacenter?->vCenter; if ($vcenter) { - Log::info('resolveVcenter: found via os → osGroup chain', ['vcenter_id' => $vcenter->id]); + Log::info('resolveVcenter: found via os → osGroup chain', ['os_id' => $item->os_id, 'vcenter_id' => $vcenter->id]); return $vcenter; } } @@ -229,10 +240,19 @@ class RetryProvisioningOrchestrator implements ShouldQueue ->first(); if ($osRecord) { - $os = Os::with('osGroup.host.datacenter.vCenter')->find($osRecord->os_id); + $os = Os::with(['host.datacenter.vCenter', 'osGroup.host.datacenter.vCenter'])->find($osRecord->os_id); + + // Path 2a: host gán trực tiếp trên OS + $vcenter = $os?->host?->datacenter?->vCenter; + if ($vcenter) { + Log::info('resolveVcenter: found via invoice → os.host chain', ['vcenter_id' => $vcenter->id]); + return $vcenter; + } + + // Path 2b: host qua osGroup $vcenter = $os?->osGroup?->host?->datacenter?->vCenter; if ($vcenter) { - Log::info('resolveVcenter: found via invoice_items → os_product chain', ['vcenter_id' => $vcenter->id]); + Log::info('resolveVcenter: found via invoice → os → osGroup chain', ['vcenter_id' => $vcenter->id]); return $vcenter; } } diff --git a/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php b/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php index 0f79934..2db7e2d 100644 --- a/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php +++ b/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php @@ -80,24 +80,22 @@ class SendVmCredentialsEmailJob implements ShouldQueue $isWindows = str_contains(strtolower($osGroupName), 'windows'); $osUsername = $isWindows ? 'Administrator' : 'root'; - $emailData = [ + $emailData = $this->safeUtf8([ 'company_name' => $settingValue->company ?? 'Thế Giới Số', 'company_logo' => $settingValue->logo ?? '', - 'client_name' => $customer->fullname ?? '', + 'client_name' => $customer->fullname ?? '', 'product_name' => $orderItem?->getProductAttribute()?->name ?? 'Cloud VPS', 'product_billing' => $this->billingLabel($months), 'product_price' => $orderItem ? number_format((float) $orderItem->price, 0, ',', '.') : 'N/A', 'service_host' => $vm->ip ?? 'N/A', 'service_username' => $osUsername, - 'service_port' => $vm->port, - 'service_password' => $vm->plain_password ?? '(xem tài khoản quản lý)', - 'os_name' => $vm->os - ? ($osGroupName . ' ' . $vm->os->version) - : 'Unknown OS', + 'service_port' => (string) ($vm->port ?? '22'), + 'service_password' => $vm->plain_password ?? '(xem tai khoan quan ly)', + 'os_name' => $vm->os ? ($osGroupName . ' ' . $vm->os->version) : 'Unknown OS', 'service_due_exp' => $vm->next_due_date ? date('d/m/Y', (int) $vm->next_due_date) : 'N/A', - ]; + ]); // PDF is generated inside the mail job — never pass binary blobs through queue payload $invoice = Invoice::where('order_id', $vm->order_id)->first(); @@ -198,12 +196,34 @@ class SendVmCredentialsEmailJob implements ShouldQueue private function billingLabel(?int $months): string { return match ($months) { - 1 => 'Hàng tháng', - 3 => '3 Tháng', - 6 => '6 Tháng', - 12 => 'Hàng năm', - 24 => '2 Năm', - default => ($months ?? 0) . ' Tháng', + 1 => 'Hang thang', + 3 => '3 Thang', + 6 => '6 Thang', + 12 => 'Hang nam', + 24 => '2 Nam', + default => ($months ?? 0) . ' Thang', }; } + + /** + * Sanitize tất cả string trong array thành UTF-8 hợp lệ. + * Loại bỏ ký tự binary/invalid trước khi đẩy vào queue (tránh JSON encode lỗi). + */ + private function safeUtf8(array $data): array + { + return array_map(function ($value) { + if (!is_string($value)) return $value; + + // Nếu đã là UTF-8 hợp lệ → giữ nguyên + if (mb_check_encoding($value, 'UTF-8')) return $value; + + // Thử convert từ encoding khác sang UTF-8 + $converted = mb_convert_encoding($value, 'UTF-8', mb_detect_encoding($value, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true) ?: 'ISO-8859-1'); + + // Nếu vẫn invalid → strip ký tự lỗi + return mb_check_encoding($converted, 'UTF-8') + ? $converted + : preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|[^\x09\x0A\x0D\x20-\x{10FFFF}]/u', '', $value); + }, $data); + } } diff --git a/app/Mail/InvoiceNotificationMail.php b/app/Mail/InvoiceNotificationMail.php index aab42f1..bddcf5d 100644 --- a/app/Mail/InvoiceNotificationMail.php +++ b/app/Mail/InvoiceNotificationMail.php @@ -2,19 +2,20 @@ namespace App\Mail; +use App\Models\EmailNotificationTemplate; use App\Models\Invoice; -use Illuminate\Bus\Queueable; -use Illuminate\Contracts\Queue\ShouldQueue; +use App\Services\EmailNotificationTemplateRenderer; use Illuminate\Database\Eloquent\Collection; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; -use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; -class InvoiceNotificationMail extends Mailable implements ShouldQueue +/** + * KHÔNG implement ShouldQueue — gọi từ trong queued job, gửi đồng bộ. + */ +class InvoiceNotificationMail extends Mailable { - use Queueable, SerializesModels; - public function __construct( public readonly Invoice $invoice, public readonly Collection $attachments, @@ -25,13 +26,35 @@ class InvoiceNotificationMail extends Mailable implements ShouldQueue public function envelope(): Envelope { - return new Envelope( - subject: "Thông báo hóa đơn #{$this->invoice->order_num} - {$this->companyName}", - ); + $tpl = $this->activeTemplate(); + $subject = $tpl + ? $this->resolveSubject($tpl->subject) + : "Thông báo hóa đơn #{$this->invoice->invoice_number} - {$this->companyName}"; + + return new Envelope(subject: $subject); } public function content(): Content { + $tpl = $this->activeTemplate(); + + if ($tpl) { + try { + $renderer = app(EmailNotificationTemplateRenderer::class); + $html = $renderer->render( + $tpl, + $this->invoice->load(['order.orderItems.product', 'customer', 'attachments']), + $this->companyName + ); + return new Content(htmlString: $html); + } catch (\Throwable $e) { + Log::warning('InvoiceNotificationMail: renderer failed, falling back to blade', [ + 'template_id' => $tpl->id, + 'error' => $e->getMessage(), + ]); + } + } + return new Content( view: 'emails.invoice-notification', with: [ @@ -48,4 +71,20 @@ class InvoiceNotificationMail extends Mailable implements ShouldQueue { return []; } + + private function activeTemplate(): ?EmailNotificationTemplate + { + return EmailNotificationTemplate::where('type', 'invoice_notification') + ->where('is_active', true) + ->first(); + } + + private function resolveSubject(string $subject): string + { + return str_replace( + ['{{invoice_number}}', '{{company_name}}'], + [$this->invoice->invoice_number ?? '', $this->companyName], + $subject + ); + } } diff --git a/app/Mail/VmCredentialsMail.php b/app/Mail/VmCredentialsMail.php new file mode 100644 index 0000000..cdc47aa --- /dev/null +++ b/app/Mail/VmCredentialsMail.php @@ -0,0 +1,72 @@ +emailData['os_name'] ?? '', + $this->emailData['client_name'] ?? '', + $this->emailData['company_name'] ?? '', + ], + $this->template->subject + ); + + return new Envelope(subject: $subject); + } + + public function content(): Content + { + try { + $renderer = app(EmailNotificationTemplateRenderer::class); + $html = $renderer->renderVmCredentials($this->template, $this->emailData, $this->invoice); + return new Content(htmlString: $html); + } catch (\Throwable $e) { + Log::warning('VmCredentialsMail: renderer failed, falling back to plain message', [ + 'template_id' => $this->template->id, + 'error' => $e->getMessage(), + ]); + return new Content(htmlString: '

Thông tin dịch vụ đã được chuẩn bị. Vui lòng liên hệ hỗ trợ nếu cần.

'); + } + } + + public function attachments(): array + { + if ($this->invoicePdf !== null && $this->invoicePdfFileName !== null) { + return [ + Attachment::fromData(fn() => $this->invoicePdf, $this->invoicePdfFileName) + ->withMime('application/pdf'), + ]; + } + + return []; + } +} diff --git a/app/Models/EmailNotificationTemplate.php b/app/Models/EmailNotificationTemplate.php new file mode 100644 index 0000000..7beec25 --- /dev/null +++ b/app/Models/EmailNotificationTemplate.php @@ -0,0 +1,97 @@ + 'array', + 'is_active' => 'boolean', + ]; + + /** Default blocks by template type */ + public static function defaultBlocks(string $type = 'invoice_notification'): array + { + if ($type === 'vm_credentials') { + return [ + ['id' => 'greeting', 'type' => 'greeting', 'label' => 'Lời chào', 'enabled' => true, 'order' => 1, 'config' => []], + ['id' => 'vm_credentials', 'type' => 'vm_credentials', 'label' => 'Thông tin truy cập VPS','enabled' => true, 'order' => 2, 'config' => [ + 'fields' => ['service_host', 'service_username', 'service_port', 'service_password', 'os_name'], + ]], + ['id' => 'product_info', 'type' => 'product_info', 'label' => 'Thông tin sản phẩm', 'enabled' => true, 'order' => 3, 'config' => [ + 'fields' => ['product_name', 'product_billing', 'product_price', 'service_due_exp'], + ]], + ['id' => 'custom_text', 'type' => 'custom_text', 'label' => 'Lời nhắn', 'enabled' => true, 'order' => 4, 'config' => [ + 'content' => '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ợ.', + ]], + ['id' => 'footer', 'type' => 'footer', 'label' => 'Footer', 'enabled' => true, 'order' => 5, 'config' => []], + ]; + } + + // invoice_notification (default) + return [ + ['id' => 'greeting', 'type' => 'greeting', 'label' => 'Lời chào', 'enabled' => true, 'order' => 1, 'config' => []], + ['id' => 'invoice_info', 'type' => 'invoice_info', 'label' => 'Thông tin hóa đơn', 'enabled' => true, 'order' => 2, 'config' => [ + 'fields' => ['invoice_number', 'created_at', 'due_date', 'total', 'status'], + ]], + ['id' => 'customer_info','type' => 'customer_info','label' => 'Thông tin khách hàng', 'enabled' => false, 'order' => 3, 'config' => [ + 'fields' => ['name', 'email'], + ]], + ['id' => 'order_items', 'type' => 'order_items', 'label' => 'Chi tiết sản phẩm', 'enabled' => true, 'order' => 4, 'config' => []], + ['id' => 'attachments', 'type' => 'attachments', 'label' => 'File đính kèm', 'enabled' => true, 'order' => 5, 'config' => []], + ['id' => 'custom_text', 'type' => 'custom_text', 'label' => 'Lời nhắn', 'enabled' => true, 'order' => 6, 'config' => [ + 'content' => '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ợ.', + ]], + ['id' => 'footer', 'type' => 'footer', 'label' => 'Footer', 'enabled' => true, 'order' => 7, 'config' => []], + ]; + } + + /** Field definitions exposed per block type */ + public static function blockFieldDefinitions(): array + { + return [ + 'invoice_info' => [ + ['key' => 'invoice_number', 'label' => 'Số hóa đơn'], + ['key' => 'created_at', 'label' => 'Ngày tạo'], + ['key' => 'due_date', 'label' => 'Hạn thanh toán'], + ['key' => 'total', 'label' => 'Tổng tiền'], + ['key' => 'sub_total', 'label' => 'Tiền dịch vụ'], + ['key' => 'tax', 'label' => 'Thuế'], + ['key' => 'status', 'label' => 'Trạng thái'], + ['key' => 'payment_method', 'label' => 'Phương thức TT'], + ], + 'customer_info' => [ + ['key' => 'name', 'label' => 'Tên khách hàng'], + ['key' => 'email', 'label' => 'Email'], + ['key' => 'address', 'label' => 'Địa chỉ'], + ], + 'vm_credentials' => [ + ['key' => 'service_host', 'label' => 'Địa chỉ IP'], + ['key' => 'service_username', 'label' => 'Username'], + ['key' => 'service_port', 'label' => 'Port'], + ['key' => 'service_password', 'label' => 'Mật khẩu'], + ['key' => 'os_name', 'label' => 'Hệ điều hành'], + ], + 'product_info' => [ + ['key' => 'product_name', 'label' => 'Tên sản phẩm'], + ['key' => 'product_billing', 'label' => 'Chu kỳ thanh toán'], + ['key' => 'product_price', 'label' => 'Giá dịch vụ'], + ['key' => 'service_due_exp', 'label' => 'Ngày hết hạn'], + ], + ]; + } +} diff --git a/app/Models/Notification.php b/app/Models/Notification.php new file mode 100644 index 0000000..fca2548 --- /dev/null +++ b/app/Models/Notification.php @@ -0,0 +1,54 @@ + 'boolean', + 'read_at' => 'datetime', + 'data' => 'array', + ]; + + public static function notifyCustomer(Customer $customer, array $attributes): void + { + self::create([ + 'recipient_type' => 'client', + 'recipient_id' => $customer->id, + ] + $attributes); + } + + public static function notifyAdmins(array $attributes): void + { + $adminIds = User::query() + ->whereHas('roles', fn ($query) => $query->where('guard_name', 'admin')) + ->pluck('id'); + + foreach ($adminIds as $adminId) { + self::create([ + 'recipient_type' => 'admin', + 'recipient_id' => $adminId, + ...$attributes, + ]); + } + } +} diff --git a/app/Models/Os.php b/app/Models/Os.php index c9c3d59..0869348 100644 --- a/app/Models/Os.php +++ b/app/Models/Os.php @@ -15,13 +15,19 @@ class Os extends Model 'version', 'os_group_id', 'template_id', - 'status' + 'module_host_id', + 'status', ]; public function osGroup() { return $this->belongsTo(\App\Models\OsGroup::class); } + + public function host() + { + return $this->belongsTo(\App\Models\ModuleHost::class, 'module_host_id'); + } public function getStatus() { return match ($this->attributes['status']){ diff --git a/app/Models/Post.php b/app/Models/Post.php index 0d98099..8f8dd47 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -2,28 +2,52 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\SoftDeletes; class Post extends Model { use HasFactory; + use SoftDeletes; - protected $table = 'post'; + protected $table = 'posts'; protected $fillable = [ - 'name', + 'post_category_id', + 'title', 'slug', - 'thumbnail', + 'excerpt', 'content', - 'status' + 'thumbnail', + 'status', + 'is_featured', + 'view_count', + 'reading_time', + 'meta_title', + 'meta_description', + 'published_at', ]; - public function getStatus() + + protected $casts = [ + 'is_featured' => 'boolean', + 'view_count' => 'integer', + 'reading_time' => 'integer', + 'published_at' => 'datetime', + ]; + + public function category(): BelongsTo { - return match ((int)$this->status) { - 1 => 'Published', - 2 => 'Draft', - default => 'Pending', - }; + return $this->belongsTo(PostCategory::class, 'post_category_id'); + } + + public function scopePublished(Builder $query): Builder + { + return $query + ->where('status', 'published') + ->whereNotNull('published_at') + ->where('published_at', '<=', now()); } } diff --git a/app/Models/PostCategory.php b/app/Models/PostCategory.php new file mode 100644 index 0000000..40dad6a --- /dev/null +++ b/app/Models/PostCategory.php @@ -0,0 +1,34 @@ + 'integer', + ]; + + public function posts(): HasMany + { + return $this->hasMany(Post::class, 'post_category_id'); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index 9569037..e26af55 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -35,6 +35,8 @@ class Product extends Model 'sort_order', 'product_categories_id', 'module_ip_address', + 'module_host_id', + 'module_datastore_id', 'published_at', ]; @@ -137,6 +139,16 @@ class Product extends Model return $this->belongsTo(ModuleIpAddress::class, 'module_ip_address'); } + public function provisioningHost() + { + return $this->belongsTo(\App\Models\ModuleHost::class, 'module_host_id'); + } + + public function provisioningDatastore() + { + return $this->belongsTo(\App\Models\ModuleDatastore::class, 'module_datastore_id'); + } + public function addons() { return $this->hasMany(Addon::class, 'type', 'product_type') diff --git a/app/Models/Service.php b/app/Models/Service.php index 0e491c1..63327bb 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -28,6 +28,7 @@ class Service extends Model 'expires_at' => 'datetime', 'suspended_at' => 'datetime', 'cancelled_at' => 'datetime', + 'retry_requested_at' => 'datetime', ]; // ------------------------------------------------------------------------- @@ -77,6 +78,21 @@ class Service extends Model return $this->status === 'active'; } + public function isPending(): bool + { + return $this->status === 'pending'; + } + + public function canRetryProvisioning(): bool + { + return $this->isPending() && $this->retry_requested_at === null; + } + + public function retryRequested(): bool + { + return $this->retry_requested_at !== null; + } + public function isExpired(): bool { return $this->expires_at !== null && $this->expires_at->isPast(); diff --git a/app/Models/Template.php b/app/Models/Template.php index 7a5fe2e..1f25069 100644 --- a/app/Models/Template.php +++ b/app/Models/Template.php @@ -13,12 +13,31 @@ class Template extends Model protected $fillable = [ 'type_id', + 'scenario', + 'format', + 'invoice_template_id', 'name', 'subject', 'message', - 'status' + 'status', ]; + protected $casts = [ + 'status' => 'integer', + ]; + + public const SCENARIOS = [ + 'vm_credentials' => 'Khởi tạo dịch vụ', + 'renewal_reminder' => 'Nhắc gia hạn', + 'news_notification'=> 'Thông báo tin tức', + 'invoice_pdf' => 'Hoá đơn điện tử', + ]; + + public function invoiceTemplate() + { + return $this->belongsTo(self::class, 'invoice_template_id'); + } + public function type() { return $this->belongsTo(\App\Models\Type::class); diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 0d77e47..b389f67 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -19,7 +19,7 @@ class Ticket extends Model 'description', 'status', 'room', - 'data' + 'data', ]; public function customer() diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 6c62368..76e3b34 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -19,6 +19,8 @@ class Transaction extends Model 'trans', 'amount', 'customer_id', + 'user_id', + 'note', 'status', 'type', 'proof_image', @@ -29,4 +31,9 @@ class Transaction extends Model { return $this->belongsTo(Customer::class, 'customer_id'); } + + public function user() + { + return $this->belongsTo(User::class, 'user_id'); + } } diff --git a/app/Services/EmailNotificationTemplateRenderer.php b/app/Services/EmailNotificationTemplateRenderer.php new file mode 100644 index 0000000..398a62c --- /dev/null +++ b/app/Services/EmailNotificationTemplateRenderer.php @@ -0,0 +1,385 @@ +blocks) + ->filter(fn($b) => $b['enabled'] ?? false) + ->sortBy('order') + ->values(); + + $bodyHtml = ''; + foreach ($blocks as $block) { + $bodyHtml .= $this->renderBlock($block, $invoice, $companyName); + } + + $color = htmlspecialchars($tpl->header_color ?? '#1a56db'); + $title = htmlspecialchars($tpl->header_title ?? 'Thông báo hóa đơn'); + + return $this->wrapLayout($title, $color, $bodyHtml, $companyName); + } + + /** + * Render a vm_credentials template using the emailData array from SendVmCredentialsEmailJob. + * $emailData keys: company_name, client_name, product_name, product_billing, product_price, + * service_host, service_username, service_port, service_password, os_name, service_due_exp + */ + public function renderVmCredentials(EmailNotificationTemplate $tpl, array $emailData, ?Invoice $invoice = null): string + { + $blocks = collect($tpl->blocks) + ->filter(fn($b) => $b['enabled'] ?? false) + ->sortBy('order') + ->values(); + + $companyName = $emailData['company_name'] ?? ''; + $bodyHtml = ''; + + foreach ($blocks as $block) { + $bodyHtml .= match ($block['type'] ?? '') { + 'greeting' => $this->renderGreetingFromData($emailData), + 'vm_credentials' => $this->renderVmBlock($emailData, $block['config'] ?? []), + 'product_info' => $this->renderProductInfoBlock($emailData, $block['config'] ?? []), + 'invoice_info' => $invoice ? $this->renderInvoiceInfo($invoice, $block['config'] ?? []) : '', + 'order_items' => $invoice ? $this->renderOrderItems($invoice) : '', + 'attachments' => $invoice ? $this->renderAttachments($invoice) : '', + 'custom_text' => $this->renderCustomText($block['config'] ?? []), + 'footer' => $this->renderFooterBlock($companyName), + default => '', + }; + } + + $color = htmlspecialchars($tpl->header_color ?? '#1a56db'); + $title = htmlspecialchars($tpl->header_title ?? 'Thông tin truy cập VPS'); + + return $this->wrapLayout($title, $color, $bodyHtml, $companyName); + } + + public function renderPreview(EmailNotificationTemplate $tpl): string + { + if ($tpl->type === 'vm_credentials') { + return $this->renderVmCredentials($tpl, $this->sampleVmData(), null); + } + + $invoice = $this->sampleInvoice(); + return $this->render($tpl, $invoice, 'Công ty Demo'); + } + + // ─── Block renderers ────────────────────────────────────────────────────── + + private function renderBlock(array $block, Invoice $invoice, string $companyName): string + { + return match ($block['type'] ?? '') { + 'greeting' => $this->renderGreeting($invoice), + 'invoice_info' => $this->renderInvoiceInfo($invoice, $block['config'] ?? []), + 'customer_info'=> $this->renderCustomerInfo($invoice, $block['config'] ?? []), + 'order_items' => $this->renderOrderItems($invoice), + 'attachments' => $this->renderAttachments($invoice), + 'custom_text' => $this->renderCustomText($block['config'] ?? []), + 'footer' => $this->renderFooterBlock($companyName), + default => '', + }; + } + + private function renderGreeting(Invoice $invoice): string + { + $name = htmlspecialchars($invoice->customer?->fullname ?? 'Quý khách'); + return "

Xin chào {$name},

\n"; + } + + private function renderGreetingFromData(array $data): string + { + $name = htmlspecialchars($data['client_name'] ?? 'Quý khách'); + return "

Xin chào {$name},

\n"; + } + + private function renderVmBlock(array $data, array $config): string + { + $fields = $config['fields'] ?? ['service_host', 'service_username', 'service_port', 'service_password', 'os_name']; + + $fieldMap = [ + 'service_host' => ['Địa chỉ IP', $data['service_host'] ?? '—'], + 'service_username' => ['Username', $data['service_username'] ?? '—'], + 'service_port' => ['Port', (string)($data['service_port'] ?? '—')], + 'service_password' => ['Mật khẩu', $data['service_password'] ?? '—'], + 'os_name' => ['Hệ điều hành', $data['os_name'] ?? '—'], + ]; + + $rows = ''; + foreach ($fields as $key) { + if (!isset($fieldMap[$key])) continue; + [$label, $raw] = $fieldMap[$key]; + $value = htmlspecialchars($raw); + // Highlight password with monospace + $display = $key === 'service_password' + ? "{$value}" + : $value; + $rows .= "{$label}" + . "{$display}\n"; + } + + return << +

🖥️ Thông tin truy cập VPS

+ {$rows}
+ +HTML; + } + + private function renderProductInfoBlock(array $data, array $config): string + { + $fields = $config['fields'] ?? ['product_name', 'product_billing', 'product_price', 'service_due_exp']; + + $fieldMap = [ + 'product_name' => ['Tên dịch vụ', $data['product_name'] ?? '—'], + 'product_billing' => ['Chu kỳ thanh toán', $data['product_billing'] ?? '—'], + 'product_price' => ['Giá dịch vụ', ($data['product_price'] ?? '0') . ' VNĐ'], + 'service_due_exp' => ['Ngày hết hạn', $data['service_due_exp'] ?? '—'], + ]; + + $rows = ''; + foreach ($fields as $key) { + if (!isset($fieldMap[$key])) continue; + [$label, $value] = $fieldMap[$key]; + $value = htmlspecialchars($value); + $rows .= "{$label}" + . "{$value}\n"; + } + + return << +

Thông tin dịch vụ

+ {$rows}
+ +HTML; + } + + private function renderInvoiceInfo(Invoice $invoice, array $config): string + { + $fields = $config['fields'] ?? ['invoice_number', 'created_at', 'due_date', 'total', 'status']; + + $rows = ''; + foreach ($fields as $field) { + $rows .= $this->invoiceRow($field, $invoice); + } + + return << + + {$rows} +
+ +HTML; + } + + private function invoiceRow(string $field, Invoice $invoice): string + { + [$label, $value] = match ($field) { + 'invoice_number' => ['Số hóa đơn', '#' . ($invoice->invoice_number ?? '—')], + 'created_at' => ['Ngày tạo', $invoice->created_at ? $invoice->created_at->format('d/m/Y') : '—'], + 'due_date' => ['Hạn thanh toán', $invoice->due_date ? date('d/m/Y', strtotime($invoice->due_date)) : '—'], + 'total' => ['Tổng tiền', number_format((float)$invoice->total) . ' VNĐ'], + 'sub_total' => ['Tiền dịch vụ', number_format((float)$invoice->sub_total) . ' VNĐ'], + 'tax' => ['Thuế', number_format((float)$invoice->tax) . ' VNĐ'], + 'status' => ['Trạng thái', $this->statusBadge((int)$invoice->status)], + 'payment_method' => ['Phương thức TT', htmlspecialchars($invoice->payment_method ?? '—')], + default => [$field, '—'], + }; + + return "{$label}" + . "{$value}\n"; + } + + private function statusBadge(int $status): string + { + return match ($status) { + 2 => 'Đã thanh toán', + 1 => 'Chưa thanh toán', + default => 'Đã hủy', + }; + } + + private function renderCustomerInfo(Invoice $invoice, array $config): string + { + $fields = $config['fields'] ?? ['name', 'email']; + $customer = $invoice->customer; + if (!$customer) return ''; + + $rows = ''; + foreach ($fields as $field) { + [$label, $value] = match ($field) { + 'name' => ['Tên khách hàng', htmlspecialchars($customer->fullname ?? '—')], + 'email' => ['Email', htmlspecialchars($customer->email ?? '—')], + 'address' => ['Địa chỉ', htmlspecialchars($customer->address ?? '—')], + default => [$field, '—'], + }; + $rows .= "{$label}" + . "{$value}\n"; + } + + return << +

Thông tin khách hàng

+ {$rows}
+ +HTML; + } + + private function renderOrderItems(Invoice $invoice): string + { + $order = $invoice->order; + if (!$order) return ''; + + $order->loadMissing('orderItems.product'); + $items = $order->orderItems; + if ($items->isEmpty()) return ''; + + $cycleMap = [ + 'monthly' => 'Hàng tháng', 'quarterly' => 'Hàng quý', + 'semi_annually' => '6 tháng', 'annually' => 'Hàng năm', 'biennially' => '2 năm', + ]; + + $rows = ''; + foreach ($items as $item) { + $name = htmlspecialchars($item->product?->name ?? '—'); + $server = htmlspecialchars($item->server_name ?? '—'); + $cycle = htmlspecialchars($cycleMap[$item->billing_cycle] ?? ($item->billing_cycle ?? '—')); + $price = number_format((float) $item->price) . ' VNĐ'; + + $rows .= "" + . "{$name}" + . "{$server}" + . "{$cycle}" + . "{$price}" + . "\n"; + } + + return << + + + + + + + + + + {$rows} +
Sản phẩmServerChu kỳGiá
+ +HTML; + } + + private function renderAttachments(Invoice $invoice): string + { + $invoice->loadMissing('attachments'); + if ($invoice->attachments->isEmpty()) return ''; + + $items = ''; + foreach ($invoice->attachments as $file) { + $name = htmlspecialchars($file->filename); + $url = htmlspecialchars($file->url ?? '#'); + $items .= "
  • {$name}
  • \n"; + } + + return << +

    File đính kèm

    + + +HTML; + } + + private function renderCustomText(array $config): string + { + $content = $config['content'] ?? ''; + if (!$content) return ''; + + // If plain text (no HTML tags), wrap in

    + $html = strip_tags($content) === $content + ? '

    ' . htmlspecialchars($content) . '

    ' + : $content; + + return "
    {$html}
    \n"; + } + + private function renderFooterBlock(string $companyName): string + { + $company = htmlspecialchars($companyName ?: config('app.name', 'Công ty')); + $year = date('Y'); + return "

    Trân trọng,
    {$company}

    \n"; + } + + // ─── Layout wrapper ─────────────────────────────────────────────────────── + + private function wrapLayout(string $title, string $color, string $body, string $companyName): string + { + $company = htmlspecialchars($companyName ?: config('app.name', 'Công ty')); + $year = date('Y'); + + return << + + + + +{$title} + + +
    +
    +

    {$title}

    +
    +
    + {$body} +
    +
    + © {$year} {$company}. Đây là email tự động, vui lòng không trả lời. +
    +
    + + +HTML; + } + + // ─── Sample data for preview ────────────────────────────────────────────── + + private function sampleVmData(): array + { + return [ + 'company_name' => 'Công ty Demo', + 'company_logo' => '', + 'client_name' => 'Nguyễn Văn A', + 'product_name' => 'VPS Starter', + 'product_billing' => 'Hàng tháng', + 'product_price' => '420.000', + 'service_host' => '203.113.x.x', + 'service_username' => 'root', + 'service_port' => '22', + 'service_password' => 'P@ssw0rd!2026', + 'os_name' => 'Ubuntu 22.04 LTS', + 'service_due_exp' => date('d/m/Y', strtotime('+1 month')), + ]; + } + + private function sampleInvoice(): Invoice + { + $invoice = new Invoice([ + 'invoice_number' => 'INV-20260521-00001', + 'sub_total' => 420000, + 'tax' => 0, + 'total' => 420000, + 'status' => 2, + 'payment_method' => 'credit', + ]); + $invoice->created_at = now(); + $invoice->due_date = now()->addDays(7)->toDateString(); + return $invoice; + } +} diff --git a/app/Services/InvoicePdfService.php b/app/Services/InvoicePdfService.php index b80d90b..a381fb6 100644 --- a/app/Services/InvoicePdfService.php +++ b/app/Services/InvoicePdfService.php @@ -15,18 +15,30 @@ class InvoicePdfService $setting = Setting::where('key', 'setting')->first(); $settingValue = $setting ? json_decode((string) $setting->value) : null; - $customer = $invoice->customer; - $order = $invoice->order; - $data = [ - 'invoice' => $invoice, - 'invoiceItems' => $invoice->invoiceItems, - 'customer' => $customer, - 'order' => $order, - 'companyName' => $settingValue->company ?? 'Thế Giới Số', - 'companyAddress' => $settingValue->address ?? '', - 'companyPhone' => $settingValue->phone ?? '', - 'companyEmail' => $settingValue->email ?? '', + 'invoice' => $invoice, + 'invoiceItems' => $invoice->invoiceItems, + 'customer' => $invoice->customer, + 'order' => $invoice->order, + 'companyName' => $settingValue->company ?? 'Thế Giới Số', + 'companyAddress' => $settingValue->address ?? '', + 'companyPhone' => $settingValue->hotline ?? ($settingValue->tel ?? ''), + 'companyEmail' => $settingValue->email ?? '', + 'companyLogo' => $settingValue->logo ?? '', + 'companyTaxCode' => $settingValue->tax ?? '', + 'brandColor' => $settingValue->brand_color ?? '#1a56db', + 'cfg' => [ + 'accent_color' => $settingValue->brand_color ?? '#1a56db', + 'show_logo' => true, + 'show_company_info' => true, + 'show_customer_info' => true, + 'show_order_info' => true, + 'show_items_table' => true, + 'show_summary' => true, + 'show_note' => true, + 'footer_text' => 'Cảm ơn quý khách đã sử dụng dịch vụ.', + 'footer_note' => 'Đây là hóa đơn điện tử được tạo tự động, không cần chữ ký số.', + ], ]; return Pdf::loadView('pdf.invoice', $data)->output(); @@ -36,4 +48,4 @@ class InvoicePdfService { return 'Hoa-don-' . $invoice->invoice_number . '.pdf'; } -} \ No newline at end of file +} diff --git a/app/Support/GeneratesUniqueSlug.php b/app/Support/GeneratesUniqueSlug.php new file mode 100644 index 0000000..85627b1 --- /dev/null +++ b/app/Support/GeneratesUniqueSlug.php @@ -0,0 +1,32 @@ + $modelClass + */ + public static function forModel(string $modelClass, string $source, ?int $ignoreId = null): string + { + $baseSlug = Str::slug($source); + $slug = $baseSlug !== '' ? $baseSlug : 'item'; + $baseSlug = $slug; + $counter = 1; + + while ( + $modelClass::withTrashed() + ->where('slug', $slug) + ->when($ignoreId, fn ($query) => $query->where('id', '!=', $ignoreId)) + ->exists() + ) { + $slug = "{$baseSlug}-{$counter}"; + $counter++; + } + + return $slug; + } +} diff --git a/config/services.php b/config/services.php index 2a1d616..fb504fe 100644 --- a/config/services.php +++ b/config/services.php @@ -30,4 +30,13 @@ return [ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], + 'nextjs' => [ + 'url' => env('NEXTJS_URL', 'http://localhost:3000'), + 'revalidate_secret' => env('NEXTJS_REVALIDATE_SECRET'), + ], + + 'docs' => [ + 'wordpress_posts_url' => env('DOCS_WORDPRESS_POSTS_URL', 'https://tailieu.tgs.com.vn/wp-json/wp/v2/posts'), + ], + ]; diff --git a/database/factories/PostCategoryFactory.php b/database/factories/PostCategoryFactory.php new file mode 100644 index 0000000..9f5a396 --- /dev/null +++ b/database/factories/PostCategoryFactory.php @@ -0,0 +1,29 @@ + + */ +class PostCategoryFactory extends Factory +{ + protected $model = PostCategory::class; + + public function definition(): array + { + $name = $this->faker->unique()->words(2, true); + + return [ + 'name' => Str::title($name), + 'slug' => Str::slug($name), + 'description' => $this->faker->sentence(), + 'icon' => null, + 'sort_order' => $this->faker->numberBetween(0, 10), + 'status' => $this->faker->randomElement(['active', 'inactive']), + ]; + } +} diff --git a/database/factories/PostFactory.php b/database/factories/PostFactory.php new file mode 100644 index 0000000..23e0d3d --- /dev/null +++ b/database/factories/PostFactory.php @@ -0,0 +1,37 @@ + + */ +class PostFactory extends Factory +{ + protected $model = Post::class; + + public function definition(): array + { + $title = $this->faker->unique()->sentence(6); + + return [ + 'post_category_id' => PostCategory::factory(), + 'title' => $title, + 'slug' => Str::slug($title), + 'excerpt' => $this->faker->sentence(), + 'content' => '

    ' . implode('

    ', $this->faker->paragraphs(4)) . '

    ', + 'thumbnail' => null, + 'status' => $this->faker->randomElement(['draft', 'published', 'hidden']), + 'is_featured' => $this->faker->boolean(20), + 'view_count' => $this->faker->numberBetween(0, 3000), + 'reading_time' => $this->faker->numberBetween(2, 12), + 'meta_title' => $title, + 'meta_description' => $this->faker->sentence(), + 'published_at' => now(), + ]; + } +} diff --git a/database/migrations/2026_05_21_000001_add_retry_requested_at_to_services_table.php b/database/migrations/2026_05_21_000001_add_retry_requested_at_to_services_table.php new file mode 100644 index 0000000..7167712 --- /dev/null +++ b/database/migrations/2026_05_21_000001_add_retry_requested_at_to_services_table.php @@ -0,0 +1,23 @@ +timestamp('retry_requested_at')->nullable()->after('cancelled_at')->index() + ->comment('Set once when customer requests provisioning retry'); + }); + } + + public function down(): void + { + Schema::table('services', function (Blueprint $table) { + $table->dropColumn('retry_requested_at'); + }); + } +}; diff --git a/database/migrations/2026_05_21_140000_add_vcenter_provisioning_to_products_table.php b/database/migrations/2026_05_21_140000_add_vcenter_provisioning_to_products_table.php new file mode 100644 index 0000000..cee469e --- /dev/null +++ b/database/migrations/2026_05_21_140000_add_vcenter_provisioning_to_products_table.php @@ -0,0 +1,35 @@ +unsignedBigInteger('module_host_id')->nullable()->after('module_ip_address'); + $table->unsignedBigInteger('module_datastore_id')->nullable()->after('module_host_id'); + + $table->foreign('module_host_id') + ->references('id') + ->on('module_host') + ->nullOnDelete(); + + $table->foreign('module_datastore_id') + ->references('id') + ->on('module_datastore') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropForeign(['module_host_id']); + $table->dropForeign(['module_datastore_id']); + $table->dropColumn(['module_host_id', 'module_datastore_id']); + }); + } +}; diff --git a/database/migrations/2026_05_21_150000_add_module_host_id_to_os_table.php b/database/migrations/2026_05_21_150000_add_module_host_id_to_os_table.php new file mode 100644 index 0000000..1cb538c --- /dev/null +++ b/database/migrations/2026_05_21_150000_add_module_host_id_to_os_table.php @@ -0,0 +1,27 @@ +unsignedBigInteger('module_host_id')->nullable()->after('template_id'); + $table->foreign('module_host_id') + ->references('id') + ->on('module_host') + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('os', function (Blueprint $table) { + $table->dropForeign(['module_host_id']); + $table->dropColumn('module_host_id'); + }); + } +}; diff --git a/database/migrations/2026_05_21_160000_create_email_notification_templates_table.php b/database/migrations/2026_05_21_160000_create_email_notification_templates_table.php new file mode 100644 index 0000000..24210b1 --- /dev/null +++ b/database/migrations/2026_05_21_160000_create_email_notification_templates_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('name'); + $table->string('type')->default('invoice_notification'); // invoice_notification | vm_credentials + $table->string('subject'); + $table->string('header_color', 20)->default('#1a56db'); + $table->string('header_title')->default('Thông báo hóa đơn'); + $table->json('blocks'); // array of block configs + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_notification_templates'); + } +}; diff --git a/database/migrations/2026_05_21_170000_add_scenario_to_template_table.php b/database/migrations/2026_05_21_170000_add_scenario_to_template_table.php new file mode 100644 index 0000000..8605318 --- /dev/null +++ b/database/migrations/2026_05_21_170000_add_scenario_to_template_table.php @@ -0,0 +1,53 @@ +string('scenario', 50)->nullable()->after('type_id') + ->comment('vm_credentials | renewal_reminder | news_notification | invoice_pdf'); + // format: loại nội dung + $table->enum('format', ['html', 'json'])->default('html')->after('scenario'); + // với email template: chọn invoice PDF template đính kèm + $table->unsignedBigInteger('invoice_template_id')->nullable()->after('format'); + $table->foreign('invoice_template_id') + ->references('id')->on('template')->nullOnDelete(); + }); + + // Seed default setting keys for email scenario assignments + $setting = DB::table('setting')->where('key', 'email_settings')->first(); + if (!$setting) { + DB::table('setting')->insert([ + 'key' => 'email_settings', + 'value' => json_encode([ + 'vm_template_id' => 2, + 'renewal_template_id' => 6, + 'news_template_id' => null, + 'invoice_template_id' => null, + ]), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + // Tag existing templates with scenario + DB::table('template')->where('id', 2)->update(['scenario' => 'vm_credentials']); + DB::table('template')->where('id', 6)->update(['scenario' => 'renewal_reminder']); + } + + public function down(): void + { + Schema::table('template', function (Blueprint $table) { + $table->dropForeign(['invoice_template_id']); + $table->dropColumn(['scenario', 'format', 'invoice_template_id']); + }); + DB::table('setting')->where('key', 'email_settings')->delete(); + } +}; diff --git a/database/migrations/2026_05_21_180000_add_admin_fields_to_transactions_table.php b/database/migrations/2026_05_21_180000_add_admin_fields_to_transactions_table.php new file mode 100644 index 0000000..17faa12 --- /dev/null +++ b/database/migrations/2026_05_21_180000_add_admin_fields_to_transactions_table.php @@ -0,0 +1,23 @@ +foreignId('user_id')->nullable()->after('customer_id')->constrained('user')->nullOnDelete(); + $table->text('note')->nullable()->after('status'); + }); + } + + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropConstrainedForeignId('user_id'); + $table->dropColumn('note'); + }); + } +}; diff --git a/database/migrations/2026_05_23_114349_alter_ticket_table_fix_columns.php b/database/migrations/2026_05_23_114349_alter_ticket_table_fix_columns.php new file mode 100644 index 0000000..3525f0e --- /dev/null +++ b/database/migrations/2026_05_23_114349_alter_ticket_table_fix_columns.php @@ -0,0 +1,32 @@ +string('ticket_num', 50)->nullable()->change(); + $table->string('status', 20)->default('open')->change(); + }); + } + + public function down(): void + { + DB::statement("UPDATE ticket SET status = '1' WHERE status IN ('open','pending')"); + DB::statement("UPDATE ticket SET status = '0' WHERE status IN ('closed','resolved')"); + + Schema::table('ticket', function (Blueprint $table) { + $table->integer('ticket_num')->nullable()->change(); + $table->tinyInteger('status')->default(1)->change(); + }); + } +}; diff --git a/database/migrations/2026_05_23_115634_add_uuid_to_ticket_table.php b/database/migrations/2026_05_23_115634_add_uuid_to_ticket_table.php new file mode 100644 index 0000000..dba610f --- /dev/null +++ b/database/migrations/2026_05_23_115634_add_uuid_to_ticket_table.php @@ -0,0 +1,35 @@ +char('uuid', 36)->nullable()->unique()->after('id'); + }); + + // Backfill UUID v7 for existing rows + DB::table('ticket')->whereNull('uuid')->orderBy('id')->each(function ($row) { + DB::table('ticket') + ->where('id', $row->id) + ->update(['uuid' => (string) Str::uuid()]); + }); + + Schema::table('ticket', function (Blueprint $table) { + $table->char('uuid', 36)->nullable(false)->change(); + }); + } + + public function down(): void + { + Schema::table('ticket', function (Blueprint $table) { + $table->dropColumn('uuid'); + }); + } +}; diff --git a/database/migrations/2026_05_23_160000_make_ticket_reply_user_id_nullable.php b/database/migrations/2026_05_23_160000_make_ticket_reply_user_id_nullable.php new file mode 100644 index 0000000..bf7508d --- /dev/null +++ b/database/migrations/2026_05_23_160000_make_ticket_reply_user_id_nullable.php @@ -0,0 +1,22 @@ +unsignedBigInteger('user_id')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('ticket_reply', function (Blueprint $table) { + $table->unsignedBigInteger('user_id')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_05_23_170000_create_notifications_table.php b/database/migrations/2026_05_23_170000_create_notifications_table.php new file mode 100644 index 0000000..6546d0b --- /dev/null +++ b/database/migrations/2026_05_23_170000_create_notifications_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('recipient_type', 20); // admin | client + $table->unsignedBigInteger('recipient_id'); + $table->string('type', 50); // ticket_reply | deposit_success + $table->string('title'); + $table->text('body')->nullable(); + $table->string('link')->nullable(); + $table->boolean('is_read')->default(false); + $table->timestamp('read_at')->nullable(); + $table->json('data')->nullable(); + $table->timestamps(); + + $table->index(['recipient_type', 'recipient_id', 'is_read']); + $table->index(['recipient_type', 'recipient_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_05_23_170846_make_ticket_uuid_nullable.php b/database/migrations/2026_05_23_170846_make_ticket_uuid_nullable.php new file mode 100644 index 0000000..3f09f94 --- /dev/null +++ b/database/migrations/2026_05_23_170846_make_ticket_uuid_nullable.php @@ -0,0 +1,28 @@ +uuid('uuid')->nullable()->default(null)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('ticket', function (Blueprint $table) { + $table->uuid('uuid')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/2026_05_25_000001_create_post_categories_table.php b/database/migrations/2026_05_25_000001_create_post_categories_table.php new file mode 100644 index 0000000..c2c11f1 --- /dev/null +++ b/database/migrations/2026_05_25_000001_create_post_categories_table.php @@ -0,0 +1,28 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('icon')->nullable(); + $table->integer('sort_order')->default(0); + $table->enum('status', ['active', 'inactive'])->default('active'); + $table->softDeletes(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('post_categories'); + } +}; diff --git a/database/migrations/2026_05_25_000002_create_posts_table.php b/database/migrations/2026_05_25_000002_create_posts_table.php new file mode 100644 index 0000000..354c215 --- /dev/null +++ b/database/migrations/2026_05_25_000002_create_posts_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('post_category_id')->constrained('post_categories'); + $table->string('title'); + $table->string('slug')->unique(); + $table->text('excerpt')->nullable(); + $table->longText('content'); + $table->string('thumbnail')->nullable(); + $table->enum('status', ['draft', 'published', 'hidden'])->default('draft'); + $table->boolean('is_featured')->default(false); + $table->unsignedBigInteger('view_count')->default(0); + $table->unsignedInteger('reading_time')->nullable(); + $table->string('meta_title')->nullable(); + $table->text('meta_description')->nullable(); + $table->timestamp('published_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['status', 'published_at']); + $table->index(['post_category_id', 'status']); + $table->index(['is_featured', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('posts'); + } +}; diff --git a/resources/views/pdf/invoice.blade.php b/resources/views/pdf/invoice.blade.php index 2268ed4..630a4e8 100644 --- a/resources/views/pdf/invoice.blade.php +++ b/resources/views/pdf/invoice.blade.php @@ -1,156 +1,301 @@ +@php +$companyLogo = $companyLogo ?? ''; +$companyName = $companyName ?? ''; +$companyAddress = $companyAddress ?? ''; +$companyPhone = $companyPhone ?? ''; +$companyEmail = $companyEmail ?? ''; +$companyTaxCode = $companyTaxCode ?? ''; +$brandColor = $brandColor ?? '#1a56db'; +$cfg = array_merge([ + 'accent_color' => $brandColor, + 'show_logo' => true, + 'show_company_info' => true, + 'show_customer_info' => true, + 'show_order_info' => true, + 'show_items_table' => true, + 'show_summary' => true, + 'show_note' => true, + 'footer_text' => 'Cảm ơn quý khách đã sử dụng dịch vụ.', + 'footer_note' => 'Đây là hóa đơn điện tử được tạo tự động, không cần chữ ký số.', +], $cfg ?? []); +@endphp - - - Hóa đơn #{{ $invoice->invoice_number }} - + +Hóa đơn #{{ $invoice->invoice_number }} + -
    -
    -
    -

    {{ $companyName }}

    - @if(isset($companyAddress)) -

    {{ $companyAddress }}

    - @endif - @if(isset($companyPhone)) -

    Tel: {{ $companyPhone }}

    - @endif - @if(isset($companyEmail)) -

    Email: {{ $companyEmail }}

    - @endif -
    -
    -

    HÓA ĐƠN

    -

    Mã hóa đơn: #{{ $invoice->invoice_number }}

    -

    Ngày tạo: {{ $invoice->created_at ? $invoice->created_at->format('d/m/Y') : '—' }}

    -

    Hạn thanh toán: {{ $invoice->due_date ? \Carbon\Carbon::parse($invoice->due_date)->format('d/m/Y') : '—' }}

    -

    - Trạng thái: - @if($invoice->status == 2) - Đã thanh toán - @elseif($invoice->status == 1) - Chưa thanh toán - @else - Đã hủy - @endif -

    -
    -
    +
    -
    -
    -

    Thông tin khách hàng

    -

    {{ $customer->fullname }}

    - @if($customer->email) -

    Email: {{ $customer->email }}

    - @endif - @if($customer->phone) -

    Điện thoại: {{ $customer->phone }}

    - @endif - @if($customer->address) -

    Địa chỉ: {{ $customer->address }}

    - @endif +{{-- ══ HEADER ══ --}} + + + + + + +
    + @if(($cfg['show_logo'] ?? true) && $companyLogo) + {{ $companyName }} + @endif + +

    HÓA ĐƠN ĐẶT HÀNG

    +

    (INVOICE ORDER)

    + -
    -

    Thông tin đơn hàng

    -

    Mã đơn hàng: #{{ $order->order_num ?? '—' }}

    - @if(isset($order) && $order->created_at) -

    Ngày đặt: {{ $order->created_at->format('d/m/Y') }}

    - @endif -
    - +
    + Mã hóa đơn: {{ $invoice->invoice_number }} + @if($order) + Mã đơn hàng: {{ $order->order_num }} + @endif + Hạn TT: + {{ $invoice->due_date ? \Carbon\Carbon::parse($invoice->due_date)->format('d/m/Y') : '—' }} + Trạng thái: + @if($invoice->status == 2)Đã thanh toán + @elseif($invoice->status == 1)Chưa thanh toán + @elseĐã hủy@endif +
    - - - - - - - - - - - - - @foreach($invoiceItems as $i => $item) - - - - - - - - - @endforeach - -
    #Dịch vụChu kỳSố lượngĐơn giáThành tiền
    {{ $i + 1 }}{{ $item->description ?? ($item->product?->name ?? 'Dịch vụ') }}{{ $item->billing_label }}{{ $item->quantity ?? 1 }}{{ number_format($item->unit_price, 0, ',', '.') }} đ{{ number_format($item->total_price, 0, ',', '.') }} đ
    +
    -
    - - - - - - @if($invoice->discount_amount > 0) - - - - - @endif - @if($invoice->tax > 0) - - - - - @endif - - - - -
    Tạm tính{{ number_format($invoice->sub_total, 0, ',', '.') }} đ
    Giảm giá-{{ number_format($invoice->discount_amount, 0, ',', '.') }} đ
    Thuế{{ number_format($invoice->tax, 0, ',', '.') }} đ
    Tổng cộng{{ number_format($invoice->total, 0, ',', '.') }} đ
    -
    +{{-- ══ COMPANY ══ --}} +@if($cfg['show_company_info'] ?? true) + + + + + +
    +

    {{ $companyName }}

    + @if($companyTaxCode) +
    Mã số thuế (Tax code): {{ $companyTaxCode }}
    + @endif + @if($companyAddress) +
    Địa chỉ (Address): {{ $companyAddress }}
    + @endif + @if($companyPhone) +
    Điện thoại (Tel): {{ $companyPhone }}
    + @endif + @if($companyEmail) +
    Email: {{ $companyEmail }}
    + @endif +
    +
    +@endif - @if($invoice->note) -
    - Ghi chú: {{ $invoice->note }} +{{-- ══ BUYER ══ --}} +@if($cfg['show_customer_info'] ?? true) +
    +
    + Họ tên người mua hàng (Buyer): {{ $customer?->fullname ?? '—' }}
    + @if($customer?->company) +
    Tên đơn vị: {{ $customer->company }}
    @endif + @if($customer?->identity_number) +
    MST / CMND: {{ $customer->identity_number }}
    + @endif + @if($customer?->address) +
    Địa chỉ (Address): {{ $customer->address }}
    + @endif + + + + + +
    +
    + Hình thức thanh toán: + {{ $invoice->payment_method ? strtoupper($invoice->payment_method) : 'TM/CK' }} +
    +
    +
    Email: {{ $customer?->email ?? '—' }}
    +
    +
    +@endif + +{{-- ══ ITEMS TABLE ══ --}} +@if($cfg['show_items_table'] ?? true) + + + + + + + + + + + + +@foreach($invoiceItems as $i => $item) + + + + + + + + +@endforeach + +@if($cfg['show_summary'] ?? true) +{{-- Sub-total --}} + + + + +@if($invoice->discount_amount > 0) + + + + +@endif +@php $taxRate = ($invoice->total > 0 && $invoice->tax > 0) ? round($invoice->tax / ($invoice->total - $invoice->tax) * 100) : 0; @endphp + + + + + + + + + +@endif + +
    STTTên hàng hóa, dịch vụĐơn vị tínhSố lượngĐơn giá (đ)Thành tiền (đ)
    {{ $i + 1 }}{{ $item->description ?? ($item->product?->name ?? 'Dịch vụ') }}{{ ($item->months && $item->months > 0) ? 'Tháng' : 'Lần' }}{{ number_format($item->quantity ?? 1, 2, ',', '.') }}{{ number_format($item->unit_price, 0, ',', '.') }}{{ number_format($item->total_price, 0, ',', '.') }}
    Cộng tiền hàng (Total amount excl. VAT){{ number_format($invoice->sub_total, 0, ',', '.') }}
    Giảm giá (Discount)-{{ number_format($invoice->discount_amount, 0, ',', '.') }}
    Thuế suất GTGT (VAT rate): {{ $taxRate > 0 ? $taxRate.'%' : 'Không áp dụng' }}Tiền thuế GTGT (VAT amount){{ number_format($invoice->tax, 0, ',', '.') }}
    Tổng tiền thanh toán (Total amount){{ number_format($invoice->total, 0, ',', '.') }}
    + +
    + Số tiền viết bằng chữ (Total amount in words): + {{ \App\Helpers\NumberToWords::convert((int) $invoice->total) }}. +
    +@endif + +{{-- ══ NOTE ══ --}} +@if(($cfg['show_note'] ?? true) && $invoice->note) +
    Ghi chú: {{ $invoice->note }}
    +@endif + +{{-- ══ SIGNATURE ══ --}} + + + + + + +
    +

    Người mua hàng
    (Buyer's signature)

    +

    Ký, ghi rõ họ tên

    +
    +

    {{ $customer?->fullname ?? '' }}

    +
    +

    Người bán hàng
    (Seller's signature)

    +

    Ký, đóng dấu, ghi rõ họ tên

    +
    +

    {{ $companyName }}

    +
    + +{{-- ══ FOOTER ══ --}} + -
    - \ No newline at end of file + diff --git a/resources/views/pdf/invoice_email.html b/resources/views/pdf/invoice_email.html new file mode 100644 index 0000000..642745c --- /dev/null +++ b/resources/views/pdf/invoice_email.html @@ -0,0 +1,384 @@ + + + + + + VAT Invoice + + + + + +
    +
    + + +
    +

    HÓA ĐƠN ĐẶT HÀNG

    +

    (INVOICE ORDER)

    + +

    Ngày (Date) 14 tháng (month) 04 năm (year) 2026

    +
    +
    + +
    + +
    +
    + +

    CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ

    + +
    + Mã số thuế (Tax code): + 0318443116 +
    + +
    + Địa chỉ (Address): + số 128/38 đường Tân Hương, Phường Phú Thọ Hòa, TP Hồ Chí + Minh, Việt Nam. +
    + +
    + Số tài khoản (Bank account): + 7379868 Ngân hàng TMCP Á Châu (ACB) Chi Nhánh TP. Hồ Chí + Minh +
    + +
    + Số tài khoản (Bank account): + 797379868 Ngân hàng TMCP Quân Đội (MB) Chi nhánh Phú Nhuận +
    +
    + +
    + +
    +
    + Họ tên người mua hàng (Buyer): +
    + +
    + Tên đơn vị (Company's name): + CÔNG TY TNHH SOPRO +
    + +
    + Mã số thuế (Tax code): + 0317005737 +
    + +
    + Địa chỉ (Address): + Lầu 3, 365 Lê Quang Định, Phường Bình Lợi Trung, Thành Phố + Hồ Chí Minh, Việt Nam +
    + +
    +
    + Hình thức thanh toán (Payment method): + TM/CK +
    + +
    + Số tài khoản (Bank account): +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    STTTên hàng hóa, dịch vụĐơn vị tínhSố lượngĐơn giáThành tiền
    1 + Cước phí dịch vụ thuê máy chủ từ 07/04/2026 đến + 06/10/2026 + Tháng6,00700.000,004.200.000
    2 + Cước phí dịch vụ thuê máy chủ từ 07/04/2026 đến + 06/10/2026 + Tháng6,00560.000,003.360.000
    + Cộng tiền hàng (Total amount excl. VAT) + 7.560.000
    Thuế suất GTGT (VAT rate): 10% + Tiền thuế GTGT (VAT amount) + 756.000
    + Tổng tiền thanh toán (Total amount) + 8.316.000
    + +
    + + Số tiền viết bằng chữ (Total amount in words): + + + Tám triệu ba trăm mười sáu nghìn đồng chẵn. +
    + + +
    + + diff --git a/routes/api.php b/routes/api.php index 7162dc9..0066507 100644 --- a/routes/api.php +++ b/routes/api.php @@ -12,6 +12,7 @@ use App\Http\Controllers\Api\Admin\Invoice\InvoiceController; use App\Http\Controllers\Api\Admin\Invoice\InvoiceTestController; use App\Http\Controllers\Api\Admin\Order\OrderController; use App\Http\Controllers\Api\Admin\Payment\PaymentController; +use App\Http\Controllers\Api\Admin\Addon\AddonController; use App\Http\Controllers\Api\Admin\Product\ProductCategoryController; use App\Http\Controllers\Api\Admin\Product\ProductController; use App\Http\Controllers\Api\Admin\VPS\VPSController; @@ -23,8 +24,13 @@ use App\Http\Controllers\Api\Admin\OsManager\OsController; use App\Http\Controllers\Api\Admin\IpPool\IpPoolController; use App\Http\Controllers\Api\Admin\Bank\BankController; use App\Http\Controllers\Api\Admin\EmailTemplate\EmailTemplateController; +use App\Http\Controllers\Api\Admin\EmailNotificationTemplateController; use App\Http\Controllers\Api\Admin\ActivityLog\ActivityLogController; use App\Http\Controllers\Api\Admin\Transaction\AdminTransactionController; +use App\Http\Controllers\Api\Admin\Ticket\TicketController as AdminTicketController; +use App\Http\Controllers\Api\Admin\Docs\PostCategoryController as AdminPostCategoryController; +use App\Http\Controllers\Api\Admin\Docs\PostController as AdminPostController; +use App\Http\Controllers\Api\NotificationController; use App\Http\Controllers\Api\Admin\InfraManagement\InfraManagementController; use App\Http\Services\Media; use App\Http\Services\vCenter as ServiceVCenter; @@ -44,6 +50,14 @@ use Illuminate\Support\Facades\Route; | */ +Route::prefix('admin')->middleware('api.auth:admin')->group(function () { + Route::post('posts/sync-wordpress', [AdminPostController::class, 'syncWordpress']); + Route::apiResource('post-categories', AdminPostCategoryController::class) + ->only(['index', 'store', 'show', 'update', 'destroy']); + Route::apiResource('posts', AdminPostController::class) + ->only(['index', 'store', 'show', 'update', 'destroy']); +}); + Route::prefix('v1') ->group(function () { // Invoice Test — Send invoice email with PDF attachment @@ -53,6 +67,13 @@ Route::prefix('v1') Route::post('{invoiceNumber}/send-email', 'sendInvoiceEmail'); Route::get('{invoiceNumber}/preview-pdf', 'previewInvoicePdf'); }); + + Route::prefix('admin')->middleware('api.auth:admin')->group(function () { + Route::apiResource('post-categories', AdminPostCategoryController::class) + ->only(['index', 'store', 'show', 'update', 'destroy']); + Route::apiResource('posts', AdminPostController::class) + ->only(['index', 'store', 'show', 'update', 'destroy']); + }); }) /* Api for Admin */ ->group(function () { @@ -105,6 +126,7 @@ Route::prefix('v1') Route::delete('{uuid}/destroy', 'destroy')->middleware('perm:customer.destroy'); Route::post('{uuid}/reset-password', 'resetPassword')->middleware('perm:customer.edit'); Route::post('{uuid}/add-credit', 'addCredit')->middleware('perm:customer.edit'); + Route::post('{uuid}/add-total', 'addTotal')->middleware('perm:customer.edit'); Route::get('{uuid}/services', 'getServices')->middleware('perm:customer.view'); Route::get('{uuid}/orders', 'getOrders')->middleware('perm:customer.view'); Route::get('{uuid}/invoices', 'getInvoices')->middleware('perm:customer.view'); @@ -169,6 +191,42 @@ Route::prefix('v1') ->name('force-delete'); }); + // Addons (module: product-service) + Route::prefix('addons') + ->controller(AddonController::class) + ->group(function () { + Route::get('/', 'index')->middleware('perm:product-service.view'); + Route::post('/', 'store')->middleware('perm:product-service.create'); + Route::post('bulk-update-status', 'bulkUpdateStatus')->middleware('perm:product-service.edit'); + Route::post('bulk-destroy', 'bulkDestroy')->middleware('perm:product-service.destroy'); + Route::get('{id}', 'show')->middleware('perm:product-service.view'); + Route::put('{id}', 'update')->middleware('perm:product-service.edit'); + Route::delete('{id}', 'destroy')->middleware('perm:product-service.destroy'); + }); + + Route::prefix('post-categories') + ->controller(AdminPostCategoryController::class) + ->as('post-categories.') + ->group(function () { + Route::get('/', 'index')->name('index'); + Route::post('/', 'store')->name('store'); + Route::get('{id}', 'show')->name('show'); + Route::put('{id}', 'update')->name('update'); + Route::delete('{id}', 'destroy')->name('destroy'); + }); + + Route::prefix('posts') + ->controller(AdminPostController::class) + ->as('posts.') + ->group(function () { + Route::get('/', 'index')->name('index'); + Route::post('/', 'store')->name('store'); + Route::post('sync-wordpress', 'syncWordpress')->name('sync-wordpress'); + Route::get('{id}', 'show')->name('show'); + Route::put('{id}', 'update')->name('update'); + Route::delete('{id}', 'destroy')->name('destroy'); + }); + // Orders (module: order) Route::prefix('orders') ->controller(OrderController::class) @@ -351,11 +409,27 @@ Route::prefix('v1') Route::prefix('email-templates') ->controller(EmailTemplateController::class) ->group(function () { - Route::get('/', 'index'); - Route::post('/', 'store'); - Route::get('{id}', 'show'); - Route::put('{id}', 'update'); - Route::delete('{id}', 'destroy'); + Route::get('/', 'index'); + Route::post('/', 'store'); + Route::get('email-settings', 'getEmailSettings'); + Route::put('email-settings', 'updateEmailSettings'); + Route::get('{id}', 'show'); + Route::put('{id}', 'update'); + Route::delete('{id}', 'destroy'); + Route::get('{id}/preview-invoice','previewInvoiceJson'); + }); + + // Notification Email Templates — block-based editor + Route::prefix('notification-templates') + ->controller(EmailNotificationTemplateController::class) + ->group(function () { + Route::get('/', 'index'); + Route::post('/', 'store'); + Route::get('field-definitions','fieldDefinitions'); + Route::get('{id}', 'show'); + Route::put('{id}', 'update'); + Route::delete('{id}', 'destroy'); + Route::get('{id}/preview', 'preview'); }); // Transactions — manual deposit review (module: transaction) @@ -393,6 +467,24 @@ Route::prefix('v1') Route::get('vms/powered-off', 'getPoweredOffVms')->middleware('perm:module-vcenter.view'); }); + // Tickets (support tickets) + Route::prefix('tickets') + ->controller(AdminTicketController::class) + ->group(function () { + Route::get('/', 'index'); + Route::get('{ticket_num}', 'show'); + Route::patch('{ticket_num}/status', 'updateStatus'); + Route::post('{ticket_num}/reply', 'reply'); + }); + + Route::prefix('notifications') + ->controller(NotificationController::class) + ->group(function () { + Route::get('/', fn () => app(NotificationController::class)->index(request(), 'admin')); + Route::patch('{id}/read', fn ($id) => app(NotificationController::class)->markRead(request(), 'admin', (int) $id)); + Route::patch('read-all', fn () => app(NotificationController::class)->markAllRead(request(), 'admin')); + }); + // Activity Logs Route::prefix('activity-logs') ->controller(ActivityLogController::class) diff --git a/routes/channels.php b/routes/channels.php index e8565b7..b01b52d 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -40,3 +40,11 @@ Broadcast::channel('user.{id}', function ($user, $id) { Broadcast::channel('admin-notifications', function () { return true; }, ['guards' => ['admin', 'admins-api']]); + +Broadcast::channel('ticket.{ticketNum}', function () { + return true; +}); + +Broadcast::channel('notifications.client.{id}', function () { + return true; +}); diff --git a/routes/client.php b/routes/client.php index f0c3dc2..637956d 100644 --- a/routes/client.php +++ b/routes/client.php @@ -10,6 +10,10 @@ use App\Http\Controllers\Api\Service\SnapshotController; use App\Http\Controllers\Api\Service\VmController; use App\Http\Controllers\Api\Service\VmRebuildController; use App\Http\Controllers\Api\Client\BankController as ClientBankController; +use App\Http\Controllers\Api\Client\Docs\PostCategoryController as ClientPostCategoryController; +use App\Http\Controllers\Api\Client\Docs\PostController as ClientPostController; +use App\Http\Controllers\Api\Client\Ticket\TicketController as ClientTicketController; +use App\Http\Controllers\Api\NotificationController; use App\Http\Controllers\Api\Transaction\ManualDepositController; use App\Http\Controllers\Api\Transaction\TransactionController; use Illuminate\Support\Facades\Route; @@ -59,6 +63,8 @@ Route::prefix('v1') Route::get('/', 'getService')->name('list'); Route::get('{uuid}', 'getDetail')->name('detail'); Route::get('{uuid}/console-ticket', 'getConsoleTicket')->name('console-ticket'); + Route::get('{uuid}/available-os', 'getAvailableOs')->name('available-os'); + Route::post('{uuid}/rebuild', 'rebuild')->name('rebuild'); }); Route::middleware(['api.auth']) @@ -79,6 +85,27 @@ Route::prefix('v1') ->group(function () { Route::get('payment-history', 'getClientPaymentHistory')->name('payment-history.list'); }); + + Route::middleware(['api.auth']) + ->prefix('notifications') + ->controller(NotificationController::class) + ->group(function () { + Route::get('/', fn () => app(NotificationController::class)->index(request(), 'client')); + Route::patch('{id}/read', fn ($id) => app(NotificationController::class)->markRead(request(), 'client', (int) $id)); + Route::patch('read-all', fn () => app(NotificationController::class)->markAllRead(request(), 'client')); + }); + + Route::middleware(['api.auth']) + ->prefix('tickets') + ->controller(ClientTicketController::class) + ->as('tickets.') + ->group(function () { + Route::get('/', 'index')->name('index'); + Route::post('/', 'store')->name('store'); + Route::get('{ticket_num}', 'show')->name('show'); + Route::post('{ticket_num}/reply', 'reply')->middleware('throttle:10,1')->name('reply'); + Route::patch('{ticket_num}/close', 'close')->name('close'); + }); }); // Services (Products) - Public & Protected Routes @@ -97,6 +124,7 @@ Route::prefix('v1') ->group(function () { Route::controller(OrderController::class)->group(function () { Route::get('/', 'getList')->name('list'); + Route::get('check-server-name', 'checkServerName')->name('check-server-name'); Route::post('/', 'storeOrder')->name('store'); Route::post('{id}/pay', 'payOrder')->name('pay'); }); @@ -113,6 +141,22 @@ Route::prefix('v1') // Public bank list for deposit form Route::get('banks', [ClientBankController::class, 'index'])->name('banks.list'); + Route::prefix('post-categories') + ->controller(ClientPostCategoryController::class) + ->as('client.docs.categories.') + ->group(function () { + Route::get('/', 'index')->name('index'); + }); + + Route::prefix('posts') + ->controller(ClientPostController::class) + ->as('client.docs.posts.') + ->group(function () { + Route::get('/', 'index')->name('index'); + Route::get('featured', 'featured')->name('featured'); + Route::get('{slug}', 'show')->name('show'); + }); + // Product Categories - Public Routes (no auth required) Route::prefix('group') ->as('product-categories.')