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:
@@ -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',
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user