feat: add GeneratesUniqueSlug class for unique slug generation

feat: create PostCategoryFactory for generating post category data

feat: create PostFactory for generating post data

feat: add retry_requested_at column to services table

feat: add vcenter provisioning fields to products table

feat: add module_host_id to os table

feat: create email_notification_templates table for email templates

feat: add scenario and format fields to template table

feat: add admin fields to transactions table

fix: update ticket table columns and types

feat: add uuid to ticket table and backfill existing records

fix: make ticket_reply user_id nullable

feat: create notifications table for user notifications

fix: make ticket uuid nullable

feat: create post_categories table for blog categories

feat: create posts table for blog posts

feat: add invoice email template for PDF generation
This commit is contained in:
baodev0
2026-05-25 15:39:42 +07:00
parent a31d238135
commit 520f21cb11
73 changed files with 4682 additions and 440 deletions
@@ -0,0 +1,164 @@
<?php
namespace App\Http\Controllers\Api\Admin\Addon;
use App\Enums\AddonType;
use App\Http\Controllers\Controller;
use App\Models\Addon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class AddonController extends Controller
{
public function index(Request $request): JsonResponse
{
$perPage = min((int) $request->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.",
]);
}
}
@@ -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,
@@ -0,0 +1,128 @@
<?php
namespace App\Http\Controllers\Api\Admin\Docs;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\PostCategoryStoreRequest;
use App\Http\Requests\Admin\PostCategoryUpdateRequest;
use App\Http\Resources\Api\PostCategoryResource;
use App\Http\Services\NextjsRevalidationService;
use App\Models\PostCategory;
use App\Support\GeneratesUniqueSlug;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PostCategoryController extends Controller
{
public function __construct(private readonly NextjsRevalidationService $revalidation) {}
public function index(Request $request): JsonResponse
{
$perPage = min((int) $request->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.',
]);
}
}
@@ -0,0 +1,161 @@
<?php
namespace App\Http\Controllers\Api\Admin\Docs;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\PostStoreRequest;
use App\Http\Requests\Admin\PostUpdateRequest;
use App\Http\Resources\Api\PostResource;
use App\Http\Services\DocsWordpressSyncService;
use App\Http\Services\NextjsRevalidationService;
use App\Models\Post;
use App\Support\GeneratesUniqueSlug;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function __construct(
private readonly NextjsRevalidationService $revalidation,
private readonly DocsWordpressSyncService $wordpressSync,
) {}
public function index(Request $request): JsonResponse
{
$perPage = min((int) $request->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,
]);
}
}
@@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\EmailNotificationTemplate;
use App\Services\EmailNotificationTemplateRenderer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EmailNotificationTemplateController extends Controller
{
public function __construct(
private readonly EmailNotificationTemplateRenderer $renderer,
) {}
public function index(): JsonResponse
{
$templates = EmailNotificationTemplate::orderByDesc('is_active')
->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']],
];
}
}
@@ -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,
];
}
}
@@ -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,
];
}
}
@@ -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,
];
}
}
@@ -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([
@@ -0,0 +1,164 @@
<?php
namespace App\Http\Controllers\Api\Admin\Ticket;
use App\Http\Controllers\Controller;
use App\Events\TicketReplyCreated;
use App\Http\Resources\Api\Ticket\TicketResource;
use App\Http\Resources\Api\Ticket\TicketReplyResource;
use App\Models\Notification;
use App\Models\Ticket;
use App\Repositories\Ticket\TicketRepositoryInterface;
use App\Repositories\TicketReply\TicketReplyRepositoryInterface;
use Illuminate\Http\Request;
class TicketController extends Controller
{
public function __construct(
protected TicketRepositoryInterface $ticketRepository,
protected TicketReplyRepositoryInterface $ticketReplyRepository,
) {}
/**
* List all tickets (paginated, filterable by status / customer_id / search).
*/
public function index(Request $request)
{
$query = Ticket::query()
->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);
}
}
@@ -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();
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Api\Client\Docs;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\PostCategoryResource;
use App\Models\PostCategory;
use Illuminate\Http\JsonResponse;
class PostCategoryController extends Controller
{
public function index(): JsonResponse
{
$categories = PostCategory::query()
->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),
]);
}
}
@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Api\Client\Docs;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\PostResource;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function index(Request $request): JsonResponse
{
$perPage = min((int) $request->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),
]);
}
}
@@ -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',
]);
}
}
@@ -0,0 +1,210 @@
<?php
namespace App\Http\Controllers\Api\Client\Ticket;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\Ticket\TicketResource;
use App\Http\Resources\Api\Ticket\TicketReplyResource;
use App\Models\Ticket;
use App\Models\Notification;
use App\Models\TicketReply;
use App\Events\TicketReplyCreated;
use App\Repositories\Ticket\TicketRepositoryInterface;
use App\Repositories\TicketReply\TicketReplyRepositoryInterface;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class TicketController extends Controller
{
public function __construct(
protected TicketRepositoryInterface $ticketRepository,
protected TicketReplyRepositoryInterface $ticketReplyRepository,
) {}
/**
* List tickets for the authenticated customer (paginated, filterable by status).
*/
public function index(Request $request)
{
$customer = auth('client')->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()),
]);
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Notification;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function index(Request $request, string $recipientType): JsonResponse
{
$recipient = $this->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),
};
}
}
@@ -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,
@@ -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',
]),
];
}
}