diff --git a/app/Exports/TicketsExport.php b/app/Exports/TicketsExport.php new file mode 100644 index 0000000..7bc7b77 --- /dev/null +++ b/app/Exports/TicketsExport.php @@ -0,0 +1,55 @@ +query; + } + + public function moneyColumns(): array + { + return []; + } + + public function headings(): array + { + return [ + 'Mã ticket', 'Tiêu đề', 'Khách hàng', 'Email', 'Phòng ban', 'Mức ưu tiên', + 'Trạng thái', 'Số phản hồi', 'Ngày tạo', 'Cập nhật lần cuối', + ]; + } + + /** + * @param Ticket $ticket + */ + public function map($ticket): array + { + $priority = $ticket->priority(); + + return [ + $ticket->ticket_num, + $ticket->subject, + $ticket->customer?->fullname, + $ticket->customer?->email, + $ticket->roomLabel(), + $priority ? (Ticket::PRIORITY_LABELS[$priority] ?? $priority) : null, + Ticket::STATUS_LABELS[$ticket->status] ?? $ticket->status, + (int) $ticket->reply_count, + $ticket->created_at?->format('d/m/Y H:i'), + $ticket->updated_at?->format('d/m/Y H:i'), + ]; + } +} diff --git a/app/Http/Controllers/Api/Admin/Auth/AuthController.php b/app/Http/Controllers/Api/Admin/Auth/AuthController.php index ce9f3bf..a04a35c 100644 --- a/app/Http/Controllers/Api/Admin/Auth/AuthController.php +++ b/app/Http/Controllers/Api/Admin/Auth/AuthController.php @@ -6,27 +6,36 @@ use App\Http\Controllers\Controller; use App\Http\Requests\UpdateProfileRequest; use App\Http\Requests\UpdateAdminPasswordRequest; use App\Http\Resources\Api\Admin\AdminUserResource; +use App\Services\Api\Admin\AdminRefreshTokenService; use App\Services\ActivityLogger; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Validation\ValidationException; +use Symfony\Component\HttpFoundation\Cookie; use Tymon\JWTAuth\Facades\JWTAuth; -use Tymon\JWTAuth\Exceptions\JWTException; class AuthController extends Controller { private const LOGIN_MAX_ATTEMPTS = 5; private const LOGIN_DECAY_SECONDS = 60; + private const REMEMBER_TTL_SECONDS = 2592000; + private const REFRESH_COOKIE = 'admin_refresh_token'; + private const REFRESH_COOKIE_PATH = '/api/v1/administrator'; - public function __construct(private readonly ActivityLogger $activityLogger) {} + public function __construct( + private readonly ActivityLogger $activityLogger, + private readonly AdminRefreshTokenService $refreshTokens, + ) {} public function login(Request $request): JsonResponse { $request->validate([ 'username' => 'required|string', 'password' => 'required|string', + 'remember' => 'sometimes|boolean', ]); $throttleKey = 'admin-login:' . strtolower($request->string('username')->toString()) . '|' . $request->ip(); @@ -38,7 +47,10 @@ class AuthController extends Controller ], 429); } - $token = Auth::guard('admin')->attempt($request->only('username', 'password')); + $guard = Auth::guard('admin'); + $remember = $request->boolean('remember'); + $credentials = $request->only('username', 'password'); + $token = $guard->attempt($credentials); if (!$token) { RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS); @@ -61,15 +73,26 @@ class AuthController extends Controller $this->activityLogger->logAuth('login', 'admin', $user->id, $user->username ?? $user->email); - return response()->json([ + $data = [ + 'token' => $token, + 'token_type' => 'bearer', + 'expires_in' => config('jwt.ttl') * 60, + 'user' => new AdminUserResource($user), + ]; + + if ($remember) { + $refreshToken = $this->refreshTokens->create($user->id, now()->addDays(30)); + $data['refresh_expires_in'] = self::REMEMBER_TTL_SECONDS; + } + + $response = response()->json([ 'error' => false, - 'data' => [ - 'token' => $token, - 'token_type' => 'bearer', - 'expires_in' => config('jwt.ttl') * 60, - 'user' => new AdminUserResource($user), - ], + 'data' => $data, ]); + + return $remember + ? $response->withCookie($this->refreshCookie($refreshToken, self::REMEMBER_TTL_SECONDS)) + : $response->withCookie($this->forgetRefreshCookie()); } public function me(): JsonResponse @@ -116,31 +139,55 @@ class AuthController extends Controller ]); } - public function refresh(): JsonResponse + public function refresh(Request $request): JsonResponse { - try { - $newToken = JWTAuth::parseToken()->refresh(); - $user = JWTAuth::setToken($newToken)->toUser(); - - return response()->json([ - 'error' => false, - 'data' => [ - 'token' => $newToken, - 'token_type' => 'bearer', - 'expires_in' => config('jwt.ttl') * 60, - 'user' => new AdminUserResource($user), - ], - ]); - } catch (JWTException) { + if (! $request->ajax()) { return response()->json([ 'error' => true, - 'message' => 'Không thể làm mới phiên đăng nhập. Vui lòng đăng nhập lại.', - ], 401); + 'message' => 'Yêu cầu làm mới phiên không hợp lệ.', + ], 403); } + + $request->validate([ + 'refresh_token' => ['prohibited'], + ]); + + $refreshToken = $request->cookie(self::REFRESH_COOKIE); + if (! is_string($refreshToken) || $refreshToken === '') { + return response()->json([ + 'error' => true, + 'message' => 'Phiên ghi nhớ đăng nhập không tồn tại.', + ], 401)->withCookie($this->forgetRefreshCookie()); + } + + try { + $result = $this->refreshTokens->rotate($refreshToken); + } catch (ValidationException) { + return response()->json([ + 'error' => true, + 'message' => 'Refresh token không hợp lệ hoặc đã hết hạn.', + ], 401)->withCookie($this->forgetRefreshCookie()); + } + + return response()->json([ + 'error' => false, + 'data' => [ + 'token' => $result['token'], + 'token_type' => 'bearer', + 'expires_in' => config('jwt.ttl') * 60, + 'refresh_expires_in' => $result['refresh_expires_in'], + 'user' => new AdminUserResource($result['user']), + ], + ])->withCookie($this->refreshCookie($result['refresh_token'], $result['refresh_expires_in'])); } public function logout(): JsonResponse { + $refreshToken = request()->cookie(self::REFRESH_COOKIE); + if (is_string($refreshToken) && $refreshToken !== '') { + $this->refreshTokens->revoke($refreshToken); + } + $user = Auth::guard('admin')->user(); if ($user) { $this->activityLogger->logAuth('logout', 'admin', $user->id, $user->username ?? $user->email); @@ -151,6 +198,26 @@ class AuthController extends Controller return response()->json([ 'error' => false, 'message' => 'Đã đăng xuất thành công.', - ]); + ])->withCookie($this->forgetRefreshCookie()); + } + + private function refreshCookie(string $token, int $maxAgeSeconds): Cookie + { + return cookie( + self::REFRESH_COOKIE, + $token, + (int) ceil($maxAgeSeconds / 60), + self::REFRESH_COOKIE_PATH, + null, + true, + true, + false, + app()->isLocal() ? 'none' : 'strict', + ); + } + + private function forgetRefreshCookie(): Cookie + { + return cookie()->forget(self::REFRESH_COOKIE, self::REFRESH_COOKIE_PATH); } } diff --git a/app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolAllocation.php b/app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolAllocation.php index 6db514a..f0b7416 100644 --- a/app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolAllocation.php +++ b/app/Http/Controllers/Api/Admin/IpPool/Concerns/ManagesIpPoolAllocation.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin\IpPool\Concerns; use App\Models\Customer; use App\Models\IpPool; +use App\Models\IpPoolLog; use App\Services\IpPool\IpAllocationService; use App\Support\MailAttachments; use Illuminate\Http\JsonResponse; @@ -55,6 +56,45 @@ trait ManagesIpPoolAllocation ]); } + public function allLogs(Request $request): JsonResponse + { + $data = $request->validate([ + 'filter' => 'nullable|string|max:255', + 'action' => 'nullable|array', + 'action.*' => 'in:' . implode(',', [IpPoolLog::ALLOCATED, IpPoolLog::REVOKED, IpPoolLog::VM_ASSIGNED, IpPoolLog::VM_RELEASED]), + 'date_from' => 'nullable|date', + 'date_to' => 'nullable|date', + 'per_page' => 'nullable|integer|min:1|max:100', + ]); + + $paginator = IpPoolLog::with('user:id,username,fullname') + ->when($data['filter'] ?? null, function ($query, string $filter) { + $like = '%' . addcslashes($filter, '%_\\') . '%'; + $query->where(fn ($q) => $q->where('ip_address', 'like', $like) + ->orWhere('customer_name', 'like', $like) + ->orWhere('service_name', 'like', $like) + ->orWhere('vm_name', 'like', $like) + ->orWhere('email_to', 'like', $like) + ->orWhere('email_cc', 'like', $like) + ->orWhere('email_bcc', 'like', $like)); + }) + ->when($data['action'] ?? null, fn ($query, array $actions) => $query->whereIn('action', $actions)) + ->when($data['date_from'] ?? null, fn ($query, string $from) => $query->whereDate('created_at', '>=', $from)) + ->when($data['date_to'] ?? null, fn ($query, string $to) => $query->whereDate('created_at', '<=', $to)) + ->latest('id') + ->paginate($data['per_page'] ?? 20); + + return response()->json([ + 'error' => false, + 'data' => [ + 'items' => $paginator->items(), + 'total' => $paginator->total(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + ], + ]); + } + private function validateAllocation(Request $request): array { return $request->validate([ diff --git a/app/Http/Controllers/Api/Admin/IpPool/Concerns/ReadsIpPoolUsage.php b/app/Http/Controllers/Api/Admin/IpPool/Concerns/ReadsIpPoolUsage.php index 0a37288..aef6241 100644 --- a/app/Http/Controllers/Api/Admin/IpPool/Concerns/ReadsIpPoolUsage.php +++ b/app/Http/Controllers/Api/Admin/IpPool/Concerns/ReadsIpPoolUsage.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin\IpPool\Concerns; use App\Models\IpPool; use App\Models\ModuleNetwork; +use App\Services\IpPool\IpPoolDashboard; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; @@ -33,6 +34,11 @@ trait ReadsIpPoolUsage return response()->json(['error' => false, 'data' => $used]); } + public function dashboard(IpPoolDashboard $dashboard): JsonResponse + { + return response()->json(['error' => false, 'data' => $dashboard->build()]); + } + public function networks(): JsonResponse { $networks = ModuleNetwork::select(['id', 'name', 'type', 'network', 'module_host_id']) diff --git a/app/Http/Controllers/Api/Admin/Setting/SettingController.php b/app/Http/Controllers/Api/Admin/Setting/SettingController.php index 419762e..15e4884 100644 --- a/app/Http/Controllers/Api/Admin/Setting/SettingController.php +++ b/app/Http/Controllers/Api/Admin/Setting/SettingController.php @@ -15,12 +15,36 @@ use App\Support\Turnstile; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; class SettingController extends Controller { + public function verifyPassword(Request $request): JsonResponse + { + $data = $request->validate([ + 'password' => ['required', 'string'], + ], [ + 'password.required' => 'Vui lòng nhập mật khẩu hiện tại.', + ]); + + $user = $request->user('admin'); + + if (!$user || !Hash::check($data['password'], $user->password)) { + return response()->json([ + 'error' => true, + 'message' => 'Mật khẩu hiện tại không chính xác.', + ], 422); + } + + return response()->json([ + 'error' => false, + 'message' => 'Xác thực thành công.', + ]); + } + /** * GET /administrator/settings * Returns all setting keys: setting, smtp. diff --git a/app/Http/Controllers/Api/Admin/Ticket/TicketController.php b/app/Http/Controllers/Api/Admin/Ticket/TicketController.php index 0501231..fc7a635 100644 --- a/app/Http/Controllers/Api/Admin/Ticket/TicketController.php +++ b/app/Http/Controllers/Api/Admin/Ticket/TicketController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers\Api\Admin\Ticket; +use App\Exports\BrandedReportWriter; +use App\Exports\TicketsExport; use App\Http\Controllers\Controller; use App\Events\TicketReplyCreated; use App\Http\Resources\Api\Ticket\TicketResource; @@ -9,7 +11,10 @@ use App\Http\Resources\Api\Ticket\TicketReplyResource; use App\Models\Notification; use App\Models\Ticket; use App\Repositories\TicketReply\TicketReplyRepositoryInterface; +use App\Support\TicketGallery; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\BinaryFileResponse; class TicketController extends Controller { @@ -17,29 +22,19 @@ class TicketController extends Controller protected TicketReplyRepositoryInterface $ticketReplyRepository, ) {} + private const FILTER_RULES = [ + 'status' => ['nullable', 'array'], + 'status.*' => ['string', 'in:open,pending,resolved,closed'], + 'room' => ['nullable', 'string', 'max:50'], + 'customer_id' => ['nullable', 'integer'], + 'date_from' => ['nullable', 'date'], + 'date_to' => ['nullable', 'date'], + 'search' => ['nullable', 'string', 'max:255'], + ]; + public function index(Request $request) { - $filters = $request->validate([ - 'status' => ['nullable', 'array'], - 'status.*' => ['string', 'in:open,pending,resolved,closed'], - 'room' => ['nullable', 'string', 'max:50'], - 'customer_id' => ['nullable', 'integer'], - 'date_from' => ['nullable', 'date'], - 'date_to' => ['nullable', 'date'], - 'search' => ['nullable', 'string', 'max:255'], - ]); - - $query = Ticket::query() - ->with(['customer']) - ->latest('id') - ->when($filters['status'] ?? null, fn ($q, $status) => $q->whereIn('status', $status)) - ->when($filters['room'] ?? null, fn ($q, $room) => $q->where('room', $room)) - ->when($filters['customer_id'] ?? null, fn ($q, $id) => $q->where('customer_id', $id)) - ->when($filters['date_from'] ?? null, fn ($q, $from) => $q->whereDate('created_at', '>=', $from)) - ->when($filters['date_to'] ?? null, fn ($q, $to) => $q->whereDate('created_at', '<=', $to)) - ->when($filters['search'] ?? null, fn ($q, $search) => $q->where( - fn ($q) => $q->where('subject', 'like', "%{$search}%")->orWhere('ticket_num', 'like', "%{$search}%") - )); + $query = $this->filteredQuery($request->validate(self::FILTER_RULES))->with(['customer']); $paginated = $query->paginate($request->input('per_page', 15)); @@ -58,6 +53,39 @@ class TicketController extends Controller /** * Show a ticket with its replies and customer info. */ + public function export(Request $request, BrandedReportWriter $writer): BinaryFileResponse + { + $filters = $request->validate(self::FILTER_RULES + ['filter' => ['nullable', 'string', 'max:255']]); + $filters['search'] ??= $filters['filter'] ?? null; + + $query = $this->filteredQuery($filters)->with(['customer'])->withCount('reply'); + + return $writer->download(new TicketsExport($query), 'ticket-' . now()->format('Y-m-d-His') . '.xlsx'); + } + + private function filteredQuery(array $filters): Builder + { + return Ticket::query() + ->latest('id') + ->when($filters['status'] ?? null, fn ($q, $status) => $q->whereIn('status', $status)) + ->when($filters['room'] ?? null, fn ($q, $room) => $q->where('room', $room)) + ->when($filters['customer_id'] ?? null, fn ($q, $id) => $q->where('customer_id', $id)) + ->when($filters['date_from'] ?? null, fn ($q, $from) => $q->whereDate('created_at', '>=', $from)) + ->when($filters['date_to'] ?? null, fn ($q, $to) => $q->whereDate('created_at', '<=', $to)) + ->when($filters['search'] ?? null, function ($q, $search) { + $keyword = '%' . addcslashes($search, '%_\\') . '%'; + $q->where(fn ($q) => $q->where('subject', 'like', $keyword)->orWhere('ticket_num', 'like', $keyword)); + }); + } + + public function pendingCount() + { + return response()->json([ + 'error' => false, + 'data' => ['count' => Ticket::whereIn('status', Ticket::UNRESOLVED_STATUSES)->count()], + ]); + } + public function show(string $ticket_num) { $ticket = Ticket::query() @@ -74,7 +102,7 @@ class TicketController extends Controller return response()->json([ 'error' => false, - 'data' => new TicketResource($ticket), + 'data' => [...(new TicketResource($ticket))->resolve(), 'status_history' => $ticket->statusHistory()], ]); } @@ -162,4 +190,9 @@ class TicketController extends Controller 'data' => new TicketReplyResource($reply), ], 201); } + + public function image(string $ticket, int $index) + { + return TicketGallery::response(Ticket::where('ticket_num', $ticket)->firstOrFail(), $index); + } } diff --git a/app/Http/Controllers/Api/Client/Ticket/TicketController.php b/app/Http/Controllers/Api/Client/Ticket/TicketController.php index 16ff470..9d6ed14 100644 --- a/app/Http/Controllers/Api/Client/Ticket/TicketController.php +++ b/app/Http/Controllers/Api/Client/Ticket/TicketController.php @@ -3,8 +3,8 @@ 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\Http\Resources\Api\Client\Ticket\ClientTicketReplyResource; +use App\Http\Resources\Api\Client\Ticket\ClientTicketResource; use App\Jobs\Ticket\TicketJobs; use App\Mail\TicketCreated; use App\Mail\Admin\TicketAdminNotification; @@ -14,9 +14,9 @@ use App\Models\Notification; use App\Events\TicketReplyCreated; use App\Repositories\Ticket\TicketRepositoryInterface; use App\Repositories\TicketReply\TicketReplyRepositoryInterface; +use App\Support\TicketGallery; use Illuminate\Http\Request; use Illuminate\Support\Facades\Mail; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; class TicketController extends Controller @@ -26,27 +26,38 @@ class TicketController extends Controller protected TicketReplyRepositoryInterface $ticketReplyRepository, ) {} - /** - * List tickets for the authenticated customer (paginated, filterable by status). - */ public function index(Request $request) { + $filters = $request->validate([ + 'status' => ['nullable', 'string', 'max:20'], + 'room' => ['nullable', 'string', 'max:50'], + 'search' => ['nullable', 'string', 'max:100'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:50'], + ]); + $customer = auth('client')->user(); $query = Ticket::query() ->where('customer_id', $customer->id) ->latest('id'); - if ($request->filled('status')) { - $query->where('status', $request->input('status')); + foreach (['status', 'room'] as $column) { + if (filled($filters[$column] ?? null)) { + $query->where($column, $filters[$column]); + } } - $paginated = $query->paginate(15); + if (filled($filters['search'] ?? null)) { + $keyword = '%' . addcslashes($filters['search'], '%_\\') . '%'; + $query->where(fn ($q) => $q->where('subject', 'like', $keyword)->orWhere('ticket_num', 'like', $keyword)); + } + + $paginated = $query->paginate($filters['per_page'] ?? 15); return response()->json([ 'error' => false, 'data' => [ - 'items' => TicketResource::collection($paginated->items()), + 'items' => ClientTicketResource::collection($paginated->items()), 'total' => $paginated->total(), 'per_page' => $paginated->perPage(), 'current_page' => $paginated->currentPage(), @@ -73,11 +84,7 @@ class TicketController extends Controller $ticketNum = 'TKT-' . now()->format('Ymd') . '-' . strtoupper(Str::random(5)); - $gallery = []; - foreach ($request->file('images', []) as $image) { - $path = $image->store('tickets', 'public'); - $gallery[] = Storage::disk('public')->url($path); - } + $gallery = TicketGallery::store($request->file('images', []), $customer->id); $ticket = $this->ticketRepository->create([ 'uuid' => (string) Str::uuid(), @@ -103,7 +110,7 @@ class TicketController extends Controller return response()->json([ 'error' => false, 'message' => 'Ticket đã được khởi tạo.', - 'data' => new TicketResource($ticket), + 'data' => new ClientTicketResource($ticket), ], 201); } @@ -129,7 +136,7 @@ class TicketController extends Controller return response()->json([ 'error' => false, - 'data' => new TicketResource($ticket), + 'data' => new ClientTicketResource($ticket), ]); } @@ -193,7 +200,7 @@ class TicketController extends Controller return response()->json([ 'error' => false, 'message' => 'Phản hồi đã được gửi.', - 'data' => new TicketReplyResource($reply), + 'data' => new ClientTicketReplyResource($reply), ], 201); } @@ -228,7 +235,17 @@ class TicketController extends Controller return response()->json([ 'error' => false, 'message' => 'Ticket đã được đóng.', - 'data' => new TicketResource($ticket->fresh()), + 'data' => new ClientTicketResource($ticket->fresh()), ]); } + + public function image(string $ticket_num, int $index) + { + $ticket = Ticket::query() + ->where('ticket_num', $ticket_num) + ->where('customer_id', auth('client')->id()) + ->firstOrFail(); + + return TicketGallery::response($ticket, $index); + } } diff --git a/app/Http/Controllers/Api/Transaction/ManualDepositController.php b/app/Http/Controllers/Api/Transaction/ManualDepositController.php index 5922090..7eb1acc 100644 --- a/app/Http/Controllers/Api/Transaction/ManualDepositController.php +++ b/app/Http/Controllers/Api/Transaction/ManualDepositController.php @@ -314,7 +314,7 @@ class ManualDepositController extends Controller Mail::to($adminEmail)->send(new ManualDepositPendingNotification([ 'company_name' => $settingValue['company'] ?? config('app.name', 'Portal Admin'), - 'customer_name' => $customer->fullname ?: trim(($customer->firstname ?? '') . ' ' . ($customer->lastname ?? '')), + 'customer_name' => (string) $customer->fullname, 'customer_email' => $customer->email, 'customer_phone' => $customer->phone ?? null, 'client_ip' => $request->ip(), diff --git a/app/Http/Resources/Api/Client/Ticket/ClientTicketReplyResource.php b/app/Http/Resources/Api/Client/Ticket/ClientTicketReplyResource.php new file mode 100644 index 0000000..11ffd21 --- /dev/null +++ b/app/Http/Resources/Api/Client/Ticket/ClientTicketReplyResource.php @@ -0,0 +1,22 @@ +user_id !== null; + + return [ + 'message' => $this->message, + 'sender_type' => $fromStaff ? 'staff' : 'client', + 'sender_name' => $fromStaff ? (optional($this->user)->name ?: 'Nhân viên hỗ trợ') : 'Khách hàng', + 'sender_role' => $fromStaff ? 'Nhân viên hỗ trợ' : 'Khách hàng', + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/Api/Client/Ticket/ClientTicketResource.php b/app/Http/Resources/Api/Client/Ticket/ClientTicketResource.php new file mode 100644 index 0000000..a7026dd --- /dev/null +++ b/app/Http/Resources/Api/Client/Ticket/ClientTicketResource.php @@ -0,0 +1,27 @@ + $this->ticket_num, + 'subject' => $this->subject, + 'description' => $this->description, + 'status' => $this->status, + 'room' => $this->room, + 'priority' => $this->resource->priority(), + 'gallery' => TicketGallery::clientUrls($this->resource), + 'customer' => $this->whenLoaded('customer', fn () => ['fullname' => $this->customer->fullname]), + 'replies' => ClientTicketReplyResource::collection($this->whenLoaded('reply')), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/Api/Ticket/TicketResource.php b/app/Http/Resources/Api/Ticket/TicketResource.php index 127e8af..6f0686f 100644 --- a/app/Http/Resources/Api/Ticket/TicketResource.php +++ b/app/Http/Resources/Api/Ticket/TicketResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources\Api\Ticket; +use App\Support\TicketGallery; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -18,7 +19,7 @@ class TicketResource extends JsonResource 'status' => $this->status, 'room' => $this->room, 'data' => $this->data ? (is_string($this->data) ? json_decode($this->data, true) : $this->data) : null, - 'gallery' => $this->gallery ?? [], + 'gallery' => TicketGallery::adminUrls($this->resource), 'customer_id' => $this->customer_id, 'customer' => $this->whenLoaded('customer', fn() => [ 'id' => $this->customer->id, diff --git a/app/Http/Services/Hosting.php b/app/Http/Services/Hosting.php index f87c285..e04e4a9 100644 --- a/app/Http/Services/Hosting.php +++ b/app/Http/Services/Hosting.php @@ -117,7 +117,7 @@ class Hosting ]) ->acceptJson() ->post("https://$this->url/api/v2/clients", [ - 'name' => $product_data->Customer->firstname . " " . $product_data->Customer->lastname, + 'name' => (string) $product_data->Customer->fullname, 'company' => $product_data->Customer->company, 'login' => $product_data->Customer->email, 'email' => $product_data->Customer->email, diff --git a/app/Http/Services/Telegram.php b/app/Http/Services/Telegram.php index b629e0b..2f6a09a 100644 --- a/app/Http/Services/Telegram.php +++ b/app/Http/Services/Telegram.php @@ -82,7 +82,7 @@ class Telegram { $msg = "🔔 Yêu cầu nạp tiền chờ duyệt!\n"; $msg .= "--------------------------------\n"; - $msg .= "👤 Khách: {$customer->firstname} {$customer->lastname}\n"; + $msg .= "👤 Khách: {$customer->fullname}\n"; $msg .= "📧 Email: {$customer->email}\n"; $msg .= "💵 Số tiền: " . number_format($transaction->amount, 0, ',', '.') . " VNĐ\n"; $msg .= "🧾 Mã GD: {$transaction->public_code}\n"; diff --git a/app/Jobs/Hosting/CreateHostingJobs.php b/app/Jobs/Hosting/CreateHostingJobs.php index c305748..68cbc64 100644 --- a/app/Jobs/Hosting/CreateHostingJobs.php +++ b/app/Jobs/Hosting/CreateHostingJobs.php @@ -153,7 +153,7 @@ class CreateHostingJobs implements ShouldQueue $data = [ 'company_name' => json_decode($setting->value)->company, 'company_logo' => json_decode($setting->value)->logo, - 'client_name' => $product->Customer->firstname . " " . $product->Customer->lastname, + 'client_name' => (string) $product->Customer->fullname, 'product_name' => $product->GetInvoiceItem()->GetItemProduct()->name, 'product_billing' => Helper::BillingCycle($product->GetInvoiceItem()->billing), 'product_price' => number_format($product->GetInvoiceItem()->amount,0,',','.'), diff --git a/app/Jobs/Mail/ExpiryMail.php b/app/Jobs/Mail/ExpiryMail.php index f469164..7eddea2 100644 --- a/app/Jobs/Mail/ExpiryMail.php +++ b/app/Jobs/Mail/ExpiryMail.php @@ -57,7 +57,7 @@ class ExpiryMail implements ShouldQueue $data = [ 'company_name' => json_decode($setting->value)->company, 'company_logo' => json_decode($setting->value)->logo, - 'client_name' => $product->Customer->firstname . " " . $product->Customer->lastname, + 'client_name' => (string) $product->Customer->fullname, 'product_name' => $product->GetInvoiceItem()->GetItemProduct()->name, 'service_due_exp' => Carbon::parse($product->next_due_date)->format('d/m/Y'), 'service_remaining' => (int) Carbon::now()->diffInDays(Carbon::parse($product->next_due_date), false) + 1 diff --git a/app/Jobs/Ticket/TicketJobs.php b/app/Jobs/Ticket/TicketJobs.php index 6557315..ed9431e 100644 --- a/app/Jobs/Ticket/TicketJobs.php +++ b/app/Jobs/Ticket/TicketJobs.php @@ -38,10 +38,10 @@ class TicketJobs implements ShouldQueue // Gửi thông báo Telegram khi tạo ticket mới $message = "🆕 Ticket mới được tạo!\n"; - $message .= "👤 Khách: {$this->customer->firstname} {$this->customer->lastname} ({$this->customer->email})\n"; + $message .= "👤 Khách: {$this->customer->fullname} ({$this->customer->email})\n"; $message .= "🎫 Số ticket: #{$this->ticket->ticket_num}\n"; $message .= "📄 Tiêu đề: {$this->ticket->subject}\n"; - $message .= "🏢 Phòng ban: {$this->ticket->room}\n"; + $message .= "🏢 Phòng ban: {$this->ticket->roomLabel()}\n"; $message .= "📝 Nội dung: " . $cleanDescription; // Kiểm tra độ dài message (Telegram giới hạn 4096 ký tự) diff --git a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php index 15da1a6..3879621 100644 --- a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php +++ b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php @@ -261,7 +261,7 @@ class AssignIpToGuestJob implements ShouldQueue "🖥️ VM ID: {$this->vmId}\n" . "📦 Sản phẩm: " . ($product?->name ?? 'N/A') . "\n" . "🧾 Đơn hàng: #" . ($order?->order_num ?? 'N/A') . "\n" . - "👤 Khách hàng: " . ($customer ? trim($customer->firstname . ' ' . $customer->lastname) : 'N/A') . "\n" . + "👤 Khách hàng: " . ($customer?->fullname ?: 'N/A') . "\n" . "📧 Email: " . ($customer?->email ?? 'N/A') . "\n" . "🔁 Số lần thử: {$maxAttempts}\n" . "⚡ Trạng thái: VM đã bị đánh dấu inactive\n" . diff --git a/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php b/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php index 91ad433..cba044e 100644 --- a/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php +++ b/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php @@ -237,7 +237,7 @@ class UpdateVmConfigJob implements ShouldQueue "━━━━━━━━━━━━━━━━━━━━\n" . "🖥️ VM ID: {$this->vmId}\n" . "📦 Server: " . ($vm?->name ?? 'N/A') . "\n" . - "👤 Khách hàng: " . ($customer ? trim($customer->firstname . ' ' . $customer->lastname) : 'N/A') . "\n" . + "👤 Khách hàng: " . ($customer?->fullname ?: 'N/A') . "\n" . "📧 Email: " . ($customer?->email ?? 'N/A') . "\n" . "❌ Lỗi: " . htmlspecialchars($e->getMessage()) . "\n" . "⚡ Trạng thái: VM đã bị đánh dấu inactive\n" . diff --git a/app/Mail/Admin/TicketAdminNotification.php b/app/Mail/Admin/TicketAdminNotification.php index 16344ed..5740021 100644 --- a/app/Mail/Admin/TicketAdminNotification.php +++ b/app/Mail/Admin/TicketAdminNotification.php @@ -3,6 +3,7 @@ namespace App\Mail\Admin; use App\Mail\TemplateMailable; +use App\Support\MailGlobals; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\SerializesModels; @@ -28,11 +29,11 @@ class TicketAdminNotification extends TemplateMailable implements ShouldQueue return [ 'ticket_num' => $this->ticket->ticket_num, 'subject_text' => $this->ticket->subject, - 'room' => $this->ticket->room ?? 'N/A', - 'customer_name' => trim($this->customer->firstname . ' ' . $this->customer->lastname) ?: 'N/A', + 'room' => $this->ticket->roomLabel() ?? 'N/A', + 'customer_name' => $this->customer->fullname ?: 'N/A', 'customer_email'=> $this->customer->email, 'description' => mb_substr($description, 0, 500) . (mb_strlen($description) > 500 ? '…' : ''), - 'ticket_url' => config('app.frontend_url') . '/tickets/' . $this->ticket->ticket_num, + 'ticket_url' => MailGlobals::frontendUrl() . '/tickets/' . $this->ticket->ticket_num, ]; } } diff --git a/app/Mail/InvoiceNotificationMail.php b/app/Mail/InvoiceNotificationMail.php index a147554..11d085f 100644 --- a/app/Mail/InvoiceNotificationMail.php +++ b/app/Mail/InvoiceNotificationMail.php @@ -163,7 +163,7 @@ class InvoiceNotificationMail extends TemplateMailable } return '' - . '
' + . '' . 'Ghi chú: ' . htmlspecialchars((string) $this->invoice->note, ENT_QUOTES, 'UTF-8') . '
'; diff --git a/app/Mail/IpAllocationMail.php b/app/Mail/IpAllocationMail.php index 24fcc80..d2ca936 100644 --- a/app/Mail/IpAllocationMail.php +++ b/app/Mail/IpAllocationMail.php @@ -107,7 +107,7 @@ class IpAllocationMail extends TemplateMailable ->map(fn (string $title) => '' . $title . '') ->implode(''); - return '' + return '
' . '' . $head . '' . $rows . '
'; } diff --git a/app/Mail/TicketCreated.php b/app/Mail/TicketCreated.php index 7e3f282..1f75461 100644 --- a/app/Mail/TicketCreated.php +++ b/app/Mail/TicketCreated.php @@ -3,6 +3,7 @@ namespace App\Mail; use App\Support\EmailTemplateShell; +use App\Support\MailGlobals; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Queue\SerializesModels; @@ -28,19 +29,14 @@ class TicketCreated extends TemplateMailable implements ShouldQueue protected function variables(): array { - $room = $this->ticket->room ?? null; + $room = $this->ticket->roomLabel(); return [ - 'full_name' => trim("{$this->customer->firstname} {$this->customer->lastname}") ?: 'Quý khách', - 'ticket_num' => $this->ticket->ticket_num, - 'subject' => $this->ticket->subject, - 'room_row' => $room - ? EmailTemplateShell::row('Phòng ban', e($room)) - : '', - 'ticket_url' => rtrim(config('app.frontend_url', ''), '/') . '/tickets/' . $this->ticket->ticket_num, - 'app_name' => config('app.name', 'Thế Giới Số'), - 'support_email' => 'support@tgs.com.vn', - 'support_phone' => '(028) 7309. 7379', + 'full_name' => $this->customer->fullname ?: 'Quý khách', + 'ticket_num' => $this->ticket->ticket_num, + 'subject' => $this->ticket->subject, + 'room_row' => $room ? EmailTemplateShell::row('Phòng ban', e($room)) : '', + 'ticket_url' => MailGlobals::frontendUrl() . '/tickets/' . $this->ticket->ticket_num, ]; } } diff --git a/app/Models/AdminRefreshToken.php b/app/Models/AdminRefreshToken.php new file mode 100644 index 0000000..ae4181c --- /dev/null +++ b/app/Models/AdminRefreshToken.php @@ -0,0 +1,24 @@ + 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/IpPoolLog.php b/app/Models/IpPoolLog.php index 3e76daf..ac729bf 100644 --- a/app/Models/IpPoolLog.php +++ b/app/Models/IpPoolLog.php @@ -18,7 +18,7 @@ class IpPoolLog extends Model protected $fillable = [ 'ip_pool_id', 'ip_address', 'action', 'effective_date', 'status_from', 'status_to', - 'customer_id', 'customer_name', 'service_name', 'service_id', 'vm_name', 'email_to', 'user_id', 'note', + 'customer_id', 'customer_name', 'service_name', 'service_id', 'vm_name', 'email_to', 'email_cc', 'email_bcc', 'user_id', 'note', ]; public static function record(IpPool $pool, string $action, string $statusFrom, array $attributes = []): self diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index 1749f95..ee12975 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -5,11 +5,32 @@ namespace App\Models; use App\Traits\LogsActivity; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; class Ticket extends Model { use HasFactory, LogsActivity; + public const UNRESOLVED_STATUSES = ['open', 'pending']; + + public const STATUS_LABELS = [ + 'open' => 'Mở', + 'pending' => 'Đang xử lý', + 'resolved' => 'Đã giải quyết', + 'closed' => 'Đã đóng', + ]; + + public const PRIORITY_LABELS = [ + 'low' => 'Thấp', + 'medium' => 'Trung bình', + 'high' => 'Cao', + ]; + + public const ROOM_LABELS = [ + 'technical' => 'Kỹ thuật', + 'sales' => 'Bán hàng', + ]; + protected $table = 'ticket'; protected $fillable = [ @@ -28,6 +49,39 @@ class Ticket extends Model 'gallery' => 'array', ]; + public function priority(): ?string + { + $data = is_string($this->data) ? json_decode($this->data, true) : $this->data; + + return is_array($data) ? ($data['priority'] ?? null) : null; + } + + /** + * @return Collection + */ + public function statusHistory(): Collection + { + return ActivityLog::query() + ->where('subject_type', self::class) + ->where('subject_id', $this->id) + ->whereIn('event', ['created', 'updated']) + ->whereNotNull('new_values->status') + ->latest('id') + ->get(['old_values', 'new_values', 'causer_type', 'causer_name', 'created_at']) + ->map(fn (ActivityLog $log) => [ + 'from' => $log->old_values['status'] ?? null, + 'to' => $log->new_values['status'], + 'causer_type' => $log->causer_type, + 'causer_name' => $log->causer_name, + 'created_at' => $log->created_at, + ]); + } + + public function roomLabel(): ?string + { + return $this->room ? (self::ROOM_LABELS[$this->room] ?? $this->room) : null; + } + public function customer() { return $this->belongsTo(\App\Models\Customer::class); diff --git a/app/Services/Api/Admin/AdminRefreshTokenService.php b/app/Services/Api/Admin/AdminRefreshTokenService.php new file mode 100644 index 0000000..f3e161f --- /dev/null +++ b/app/Services/Api/Admin/AdminRefreshTokenService.php @@ -0,0 +1,68 @@ + $userId, + 'token' => hash('sha256', $rawToken), + 'expires_at' => $expiresAt, + ]); + + return $rawToken; + } + + public function rotate(string $rawToken): array + { + return DB::transaction(function () use ($rawToken): array { + $record = AdminRefreshToken::where('token', hash('sha256', $rawToken)) + ->lockForUpdate() + ->first(); + + if (! $record || $record->expires_at->isPast()) { + $record?->delete(); + + throw ValidationException::withMessages([ + 'refresh_token' => ['Refresh token không hợp lệ hoặc đã hết hạn.'], + ]); + } + + $user = $record->user; + if (! $user) { + $record->delete(); + + throw ValidationException::withMessages([ + 'refresh_token' => ['Tài khoản không hợp lệ.'], + ]); + } + + $expiresAt = $record->expires_at->copy(); + $record->delete(); + $newRefreshToken = $this->create($user->id, $expiresAt); + + return [ + 'user' => $user, + 'token' => JWTAuth::fromUser($user), + 'refresh_token' => $newRefreshToken, + 'refresh_expires_in' => (int) max(0, now()->diffInSeconds($expiresAt, false)), + ]; + }); + } + + public function revoke(string $rawToken): void + { + AdminRefreshToken::where('token', hash('sha256', $rawToken))->delete(); + } +} diff --git a/app/Services/Api/Order/OrderService.php b/app/Services/Api/Order/OrderService.php index 66e85e6..52157e3 100644 --- a/app/Services/Api/Order/OrderService.php +++ b/app/Services/Api/Order/OrderService.php @@ -154,7 +154,7 @@ class OrderService AdminOrderNotificationJob::dispatch([ 'order_num' => $order->order_num, - 'customer_name' => trim($customer->firstname . ' ' . $customer->lastname), + 'customer_name' => (string) $customer->fullname, 'customer_email' => $customer->email, 'client_ip' => $order->client_ip ?? request()?->ip(), 'product_name' => $product?->name ?? 'N/A', diff --git a/app/Services/IpPool/IpAllocationService.php b/app/Services/IpPool/IpAllocationService.php index 98b1598..1cc859d 100644 --- a/app/Services/IpPool/IpAllocationService.php +++ b/app/Services/IpPool/IpAllocationService.php @@ -45,6 +45,7 @@ class IpAllocationService return DB::transaction(function () use ($ids, $customer, $data, $admin, $files) { $pools = $this->allocatable($this->pools($ids, lock: true)); + $copies = $this->emailCopies(IpAllocationMail::ALLOCATED, $data['cc'] ?? null); foreach ($pools as $pool) { $from = $pool->status; @@ -67,6 +68,7 @@ class IpAllocationService 'customer_name' => $customer->fullname, 'service_name' => $pool->service_name, 'email_to' => $data['to'], + ...$copies, 'note' => $data['note'] ?? null, ]); } @@ -116,6 +118,7 @@ class IpAllocationService $groups = $this->revocationGroups($this->revocable($this->pools($ids, lock: true))); return $groups->map(function (Collection $pools, string $key) use ($data, $recipients, $admin) { + $copies = $this->emailCopies(IpAllocationMail::REVOKED, $recipients[$key]['cc'] ?? null); $mail = $this->revocationMail($pools, $data, $recipients[$key]['subject'] ?? null); $customerId = $pools->first()->customer_id; foreach ($pools as $pool) { @@ -136,6 +139,7 @@ class IpAllocationService ...$before, 'effective_date' => $data['date'], 'email_to' => $recipients[$key]['to'], + ...$copies, 'note' => $data['note'] ?? null, ]); } @@ -255,19 +259,35 @@ class IpAllocationService } } + /** + * @return array{email_cc: string|null, email_bcc: string|null} + */ + private function emailCopies(string $scenario, ?string $cc): array + { + $config = $this->templates->loadTemplate($scenario)->mailConfig(); + $join = fn (array $emails) => $emails ? implode(', ', array_values(array_unique($emails))) : null; + + return [ + 'email_cc' => $join([...EmailRecipients::parse($cc), ...($config['cc'] ?? [])]), + 'email_bcc' => $join($config['bcc'] ?? []), + ]; + } + private function send(IpAllocationMail $mail, string $to, ?string $cc, ?int $customerId, User $admin): void { AutoEmailGate::markManual(); $template = $this->templates->loadTemplate($mail->type); $toList = EmailRecipients::parse($to); $ccList = EmailRecipients::parse($cc); + $copies = $this->emailCopies($mail->type, $cc); MailLog::create([ 'template_id' => $template->id, 'subject' => $mail->envelope()->subject, 'from_address' => $template->mailConfig()['from_email'] ?? config('mail.from.address'), 'to' => implode(', ', $toList), - 'cc' => $ccList ? implode(', ', $ccList) : null, + 'cc' => $copies['email_cc'], + 'bcc' => $copies['email_bcc'], 'customer_id' => $customerId, 'sent_by' => $admin->id, 'message' => (clone $mail)->render(), diff --git a/app/Services/IpPool/IpPoolDashboard.php b/app/Services/IpPool/IpPoolDashboard.php new file mode 100644 index 0000000..51408b5 --- /dev/null +++ b/app/Services/IpPool/IpPoolDashboard.php @@ -0,0 +1,87 @@ +selectRaw("COALESCE(NULLIF(ip_block, ''), '') as ip_block") + ->selectRaw('COUNT(*) as total') + ->selectRaw('SUM(ip_role = ?) as addon', [self::ADDON_ROLE]) + ->selectRaw("SUM(status = 'assigned') as assigned") + ->selectRaw("SUM(status = 'free') as free") + ->selectRaw("SUM(status = 'reserved') as reserved") + ->selectRaw('COALESCE(SUM(monthly_cost), 0) as cost') + ->selectRaw("COALESCE(SUM(CASE WHEN status = 'assigned' THEN selling_price END), 0) as revenue") + ->groupBy(DB::raw("COALESCE(NULLIF(ip_block, ''), '')")) + ->orderBy('ip_block') + ->get() + ->map(fn ($row) => $this->withRates([ + 'ip_block' => $row->ip_block ?: null, + 'total' => (int) $row->total, + 'addon' => (int) $row->addon, + 'assigned' => (int) $row->assigned, + 'free' => (int) $row->free, + 'reserved' => (int) $row->reserved, + 'cost' => (float) $row->cost, + 'revenue' => (float) $row->revenue, + ])); + + $summary = collect(['total', 'addon', 'assigned', 'free', 'reserved', 'cost', 'revenue']) + ->mapWithKeys(fn (string $key) => [$key => $blocks->sum($key)]) + ->all(); + $summary['primary'] = $summary['total'] - $summary['addon']; + + return [ + 'summary' => $this->withRates($summary), + 'blocks' => $blocks->values(), + 'top_customers' => $this->topCustomers(), + ]; + } + + private function withRates(array $stats): array + { + $profit = $stats['revenue'] - $stats['cost']; + + return [ + ...$stats, + 'usage_rate' => $stats['total'] ? $stats['assigned'] / $stats['total'] : 0, + 'profit' => $profit, + 'margin' => $stats['revenue'] ? $profit / $stats['revenue'] : null, + ]; + } + + private function topCustomers(): array + { + $rows = IpPool::query() + ->where('status', 'assigned') + ->where(fn ($q) => $q->whereNotNull('customer_id')->orWhere('customer_name', '!=', '')) + ->selectRaw('customer_id, MAX(customer_name) as customer_name') + ->selectRaw('COUNT(*) as ips') + ->selectRaw('COALESCE(SUM(selling_price), 0) as revenue') + ->groupBy('customer_id', DB::raw('CASE WHEN customer_id IS NULL THEN customer_name END')) + ->orderByDesc('ips') + ->orderBy('customer_name') + ->limit(self::TOP_CUSTOMERS) + ->get(); + + $customers = Customer::whereIn('id', $rows->pluck('customer_id')->filter())->get(['id', 'uuid', 'fullname'])->keyBy('id'); + + return $rows->map(fn ($row) => [ + 'customer_uuid' => $customers[$row->customer_id]->uuid ?? null, + 'customer_name' => $customers[$row->customer_id]->fullname ?? $row->customer_name, + 'ips' => (int) $row->ips, + 'revenue' => (float) $row->revenue, + ])->all(); + } +} diff --git a/app/Services/VirtualMachine/VmNotificationService.php b/app/Services/VirtualMachine/VmNotificationService.php index 1389870..e43d973 100644 --- a/app/Services/VirtualMachine/VmNotificationService.php +++ b/app/Services/VirtualMachine/VmNotificationService.php @@ -34,7 +34,7 @@ class VmNotificationService VMCreationFailedJob::dispatch([ 'customer_email' => $customer->email, - 'customer_name' => trim($customer->firstname . ' ' . $customer->lastname), + 'customer_name' => (string) $customer->fullname, 'product_name' => $product?->name ?? 'Unknown Product', 'order_id' => $item->order->id, 'order_num' => $item->order->order_num, @@ -95,7 +95,7 @@ class VmNotificationService try { $order = $item->order; $customer = $order->customer; - $name = trim($customer->firstname . ' ' . $customer->lastname); + $name = (string) $customer->fullname; broadcast(new OrderProvisioned( orderId: $order->id, diff --git a/app/Support/EmailTemplateShell.php b/app/Support/EmailTemplateShell.php index 73342c1..a2d22b2 100644 --- a/app/Support/EmailTemplateShell.php +++ b/app/Support/EmailTemplateShell.php @@ -6,6 +6,12 @@ class EmailTemplateShell { private const BRAND = '#f16723'; + public const TABLE_BORDER = '#f6d2b8'; + + public const TABLE_STYLE = 'box-sizing: border-box; width: 100%; border: 1px solid ' . self::TABLE_BORDER . '; border-radius: 10px; border-collapse: separate; overflow: hidden;'; + + public const HEAD_CELL_STYLE = 'box-sizing: border-box; padding: 11px 14px; color: #c2410c; font-size: 13px; font-weight: bold; letter-spacing: .4px; border-bottom: 1px solid ' . self::TABLE_BORDER . '; background-color: #fff5ee;'; + private const SERVICE_LINKS = [ 'Premium Hosting' => 'https://tgs.com.vn/premium-hosting/', 'Cloud Server' => 'https://tgs.com.vn/cloud-server/', @@ -25,7 +31,7 @@ class EmailTemplateShell public static function body(string $innerHtml): string { - return '' + return '' . $innerHtml . ''; } @@ -74,7 +80,7 @@ class EmailTemplateShell public static function note(string $html, string $label = 'Lưu ý:'): string { return '' - . '
' + . '' . '' . $label . ' ' . $html . '
'; } @@ -99,15 +105,15 @@ class EmailTemplateShell $html .= self::row($label, $value); } - return '' + return '
' . $html . $extraRowsHtml . '
'; } public static function row(string $label, string $valueHtml): string { - return '' . $label . '' - . '' . $valueHtml . ''; + return '' . $label . '' + . '' . $valueHtml . ''; } public static function header(): string @@ -148,7 +154,6 @@ HTML; $text = 'box-sizing: border-box; font-size: 12px; line-height: 18px; color: #ffffff;'; return $line('CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ') - . $line('Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh') . $line('Tel: (028) 7309. 7379  -  MST: 0318443116') . $line('Email: support@tgs.com.vn') . $line('🥇 Thế Giới Số 🥇 - Premium HOSTING • CLOUD • SERVER & WEBSITE | 15+ năm Phụng Sự🚀', '6px 30px 16px 30px'); diff --git a/app/Support/TicketGallery.php b/app/Support/TicketGallery.php new file mode 100644 index 0000000..f7bc1b7 --- /dev/null +++ b/app/Support/TicketGallery.php @@ -0,0 +1,133 @@ + $file->store("ticket-attachments/{$customerId}", self::DISK), + $files, + ); + } + + /** + * @return string[] + */ + public static function clientUrls(Ticket $ticket): array + { + return array_map( + fn (int $index) => "/v1/client/tickets/{$ticket->ticket_num}/images/{$index}", + array_keys(self::entries($ticket)), + ); + } + + /** + * @return string[] + */ + public static function adminUrls(Ticket $ticket): array + { + return array_map( + fn (int $index) => URL::temporarySignedRoute( + 'admin.tickets.image', + now()->addDay(), + ['ticket' => $ticket->ticket_num, 'index' => $index], + ), + array_keys(self::entries($ticket)), + ); + } + + public static function moveLegacyToPrivate(Ticket $ticket): bool + { + $moved = false; + + $gallery = array_map(function (string $entry) use ($ticket, &$moved) { + $location = self::locate($entry); + + if ($location === null || $location[0] !== self::LEGACY_DISK) { + return $entry; + } + + $target = "ticket-attachments/{$ticket->customer_id}/" . basename($location[1]); + Storage::disk(self::DISK)->writeStream($target, Storage::disk(self::LEGACY_DISK)->readStream($location[1])); + Storage::disk(self::LEGACY_DISK)->delete($location[1]); + $moved = true; + + return $target; + }, self::entries($ticket)); + + if ($moved) { + $ticket->forceFill(['gallery' => $gallery])->saveQuietly(); + } + + return $moved; + } + + public static function response(Ticket $ticket, int $index): StreamedResponse + { + $location = self::locate(self::entries($ticket)[$index] ?? null); + + abort_if($location === null, 404); + + [$disk, $path] = $location; + + return Storage::disk($disk)->response($path, null, ['Cache-Control' => 'private, max-age=3600']); + } + + /** + * @return string[] + */ + private static function entries(Ticket $ticket): array + { + return array_values(array_filter( + (array) ($ticket->gallery ?? []), + fn ($entry) => is_string($entry) && $entry !== '', + )); + } + + /** + * @return array{0: string, 1: string}|null + */ + private static function locate(?string $entry): ?array + { + if ($entry === null) { + return null; + } + + [$disk, $path] = [self::DISK, $entry]; + + if (preg_match('#^https?://#i', $entry)) { + $urlPath = (string) parse_url($entry, PHP_URL_PATH); + $offset = strpos($urlPath, self::LEGACY_URL_SEGMENT); + + if ($offset === false) { + return null; + } + + [$disk, $path] = [self::LEGACY_DISK, rawurldecode(substr($urlPath, $offset + strlen(self::LEGACY_URL_SEGMENT)))]; + } + + if (str_contains($path, '..') || ! Storage::disk($disk)->exists($path)) { + return null; + } + + return [$disk, $path]; + } +} diff --git a/database/migrations/2026_10_16_000001_add_email_cc_bcc_to_ip_pool_logs.php b/database/migrations/2026_10_16_000001_add_email_cc_bcc_to_ip_pool_logs.php new file mode 100644 index 0000000..be3b849 --- /dev/null +++ b/database/migrations/2026_10_16_000001_add_email_cc_bcc_to_ip_pool_logs.php @@ -0,0 +1,23 @@ +text('email_cc')->nullable()->after('email_to'); + $table->text('email_bcc')->nullable()->after('email_cc'); + }); + } + + public function down(): void + { + Schema::table('ip_pool_logs', function (Blueprint $table) { + $table->dropColumn(['email_cc', 'email_bcc']); + }); + } +}; diff --git a/database/migrations/2026_10_16_000002_move_ticket_gallery_to_private_disk.php b/database/migrations/2026_10_16_000002_move_ticket_gallery_to_private_disk.php new file mode 100644 index 0000000..d767ade --- /dev/null +++ b/database/migrations/2026_10_16_000002_move_ticket_gallery_to_private_disk.php @@ -0,0 +1,18 @@ +whereNotNull('gallery') + ->where('gallery', 'like', '%/storage/%') + ->chunkById(100, fn ($tickets) => $tickets->each(fn (Ticket $ticket) => TicketGallery::moveLegacyToPrivate($ticket))); + } + + public function down(): void {} +}; diff --git a/database/migrations/2026_10_17_000001_create_admin_refresh_tokens_table.php b/database/migrations/2026_10_17_000001_create_admin_refresh_tokens_table.php new file mode 100644 index 0000000..7b5726b --- /dev/null +++ b/database/migrations/2026_10_17_000001_create_admin_refresh_tokens_table.php @@ -0,0 +1,24 @@ +id(); + $table->foreignId('user_id')->constrained('user')->cascadeOnDelete(); + $table->string('token', 64)->unique(); + $table->timestamp('expires_at'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('admin_refresh_tokens'); + } +}; diff --git a/database/seeders/EmailTemplates/BillingTemplates.php b/database/seeders/EmailTemplates/BillingTemplates.php index f9536f6..bcee4d6 100644 --- a/database/seeders/EmailTemplates/BillingTemplates.php +++ b/database/seeders/EmailTemplates/BillingTemplates.php @@ -143,12 +143,12 @@ final class BillingTemplates { return <<<'HTML'

Kính gửi Quý Khách {{client_name}},

-

Công ty Cổ phần Giải pháp Công nghệ Thế Giới Số xin chân thành cảm ơn Quý Khách đã tin tưởng lựa chọn dịch vụ đăng ký và quản lý tên miền của chúng tôi.

+

Công ty Cổ phần Giải pháp Công nghệ Thế Giới Số xin chân thành cảm ơn Quý Khách đã tin tưởng lựa chọn dịch vụ đăng ký và quản lý tên miền của chúng tôi.

🌐 I. THÔNG TIN TÊN MIỀN
- - - +
Tên miềnNgày đăng kýNgày hết hạnNS1NS2
{{domain_name}}{{registered_at}}{{expires_at}}{{name_server_1}}{{name_server_2}}
+ +
Tên miềnNgày đăng kýNgày hết hạnNS1NS2
{{domain_name}}{{registered_at}}{{expires_at}}{{name_server_1}}{{name_server_2}}
🔐 II. THÔNG TIN TÀI KHOẢN QUẢN LÝ
@@ -156,7 +156,7 @@ final class BillingTemplates

🔒 Quý Khách vui lòng đổi mật khẩu ngay sau lần đăng nhập đầu tiên và bảo mật thông tin tài khoản.

📋 III. LƯU Ý QUAN TRỌNG – XÁC THỰC TÊN MIỀN
-

Quý Khách cần hoàn tất thủ tục xác thực tên miền theo hướng dẫn để tránh tình trạng gián đoạn dịch vụ do chưa hoàn tất xác thực.

+

Quý Khách cần hoàn tất thủ tục xác thực tên miền theo hướng dẫn để tránh tình trạng gián đoạn dịch vụ do chưa hoàn tất xác thực.

{{kyc_block}}

📮 Bản gốc vui lòng gửi về:

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

128/38 Tân Hương, Phường Phú Thọ Hòa, TP. Hồ Chí Minh

Người nhận: Ms. Vân Anh – 0818 006 119 (P. HCNS)

@@ -164,7 +164,7 @@ final class BillingTemplates
📞 IV. HỖ TRỢ KỸ THUẬT 24/7

☎ Hotline: (028) 7309 7379

✉ Email: support@tgs.com.vn

-

Một lần nữa, Thế Giới Số xin chân thành cảm ơn sự tin tưởng và đồng hành của Quý Khách. Chúng tôi luôn sẵn sàng hỗ trợ để đảm bảo trải nghiệm dịch vụ tốt nhất.

+

Một lần nữa, Thế Giới Số xin chân thành cảm ơn sự tin tưởng và đồng hành của Quý Khách. Chúng tôi luôn sẵn sàng hỗ trợ để đảm bảo trải nghiệm dịch vụ tốt nhất.

HTML; } } diff --git a/database/seeders/EmailTemplates/MarketingTemplates.php b/database/seeders/EmailTemplates/MarketingTemplates.php index c399cbb..8aaa846 100644 --- a/database/seeders/EmailTemplates/MarketingTemplates.php +++ b/database/seeders/EmailTemplates/MarketingTemplates.php @@ -60,18 +60,18 @@ final class MarketingTemplates { return <<<'HTML'

Kính gửi Quý Công ty {{customer_name}},

-

Thế Giới Số trân trọng cảm ơn Quý Công ty đã tin tưởng sử dụng dịch vụ của chúng tôi.

-

Với mục đích giúp Quý Công ty xác định quyền sở hữu hợp pháp đối với tên miền và hạn chế rủi ro vi phạm quy định pháp luật về quản lý tài nguyên Internet, quy định cụ thể tại:

+

Thế Giới Số trân trọng cảm ơn Quý Công ty đã tin tưởng sử dụng dịch vụ của chúng tôi.

+

Với mục đích giúp Quý Công ty xác định quyền sở hữu hợp pháp đối với tên miền và hạn chế rủi ro vi phạm quy định pháp luật về quản lý tài nguyên Internet, quy định cụ thể tại:

- Nghị định 147/2024/NĐ-CP ngày 09/11/2024 của Chính phủ về quản lý, cung cấp, sử dụng dịch vụ Internet và thông tin trên mạng.

- Thông tư 48/2025/TT-BKHCN ngày 25/12/2025 của Bộ Khoa học và Công nghệ hướng dẫn về quản lý và sử dụng tài nguyên Internet.

Hệ thống Thế Giới Số ghi nhận Quý Công ty hiện có {{domain_count}} tên miền chưa đủ hồ sơ để xác định quyền sở hữu hợp pháp theo quy định, cụ thể như sau:

-
STTDịch vụ
1{{domain_name}}
+
STTDịch vụ
1{{domain_name}}

📎 Hoàn tất xác thực Tổ chức – chọn 1 trong 2 cách sau

CÁCH 1  |  Hoàn thiện Bản khai (bản giấy)

Tải mẫu Bản khai, in, ký tên, đóng dấu (và đóng dấu giáp lai nếu có nhiều trang), sau đó chụp ảnh hoặc scan và phản hồi email này.

Tải mẫu Bản khai →

Gửi bản gốc đã ký, đóng dấu về:
CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ
128/38 Tân Hương, Phường Phú Thọ Hòa, TP. Hồ Chí Minh
Người nhận: Ms. Vân Anh – 0818 006 119 (P. HCNS)

HOẶC

CÁCH 2  |  Xác thực Tổ chức bằng chữ ký số

Xác thực Tổ chức trực tuyến bằng chữ ký số.

Xác thực Tổ chức bằng chữ ký số →

Lưu ý:

Quý Công ty vui lòng hoàn tất trước ngày {{supplement_deadline}} để chúng tôi cập nhật quyền sở hữu hợp pháp đối với tên miền tới hệ thống Trung tâm Internet Việt Nam, tránh ảnh hưởng đến quyền lợi của chủ thể sử dụng tên miền.

- Gián đoạn sử dụng tên miền.

- Ảnh hưởng đến các nghiệp vụ duy trì tên miền.

- Bất lợi khi xảy ra tranh chấp hoặc có thể bị thu hồi tên miền.

Nếu Quý Công ty đã hoàn tất việc bổ sung hồ sơ, xin vui lòng bỏ qua email này.

-

Trong suốt quá trình thực hiện, nếu cần hỗ trợ, đội ngũ chuyên viên Thế Giới Số luôn sẵn sàng đồng hành cùng Quý Công ty.

+

Trong suốt quá trình thực hiện, nếu cần hỗ trợ, đội ngũ chuyên viên Thế Giới Số luôn sẵn sàng đồng hành cùng Quý Công ty.

Trân trọng,

HTML; } diff --git a/routes/admin-media.php b/routes/admin-media.php index 0788e2a..0f3603c 100644 --- a/routes/admin-media.php +++ b/routes/admin-media.php @@ -1,5 +1,6 @@ whereNumber('index') ->name('proof-image'); }); + +Route::prefix('v1/administrator/tickets') + ->as('admin.tickets.') + ->middleware('signed') + ->group(function () { + Route::get('{ticket}/images/{index}', [AdminTicketController::class, 'image']) + ->whereNumber('index') + ->name('image'); + }); diff --git a/routes/api.php b/routes/api.php index 4fd31c2..46bc0d0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -20,6 +20,7 @@ Route::prefix('v1') ->group(function () { Route::prefix('administrator')->group(function () { Route::post('login', [AuthController::class, 'login'])->middleware(['throttle:30,1', 'turnstile']); + Route::post('refresh', [AuthController::class, 'refresh'])->middleware('throttle:30,1'); Route::middleware('api.auth:admin')->group(function () { require __DIR__.'/api/admin/auth.php'; diff --git a/routes/api/admin/auth.php b/routes/api/admin/auth.php index 0d032d7..281e4d1 100644 --- a/routes/api/admin/auth.php +++ b/routes/api/admin/auth.php @@ -7,8 +7,6 @@ use Illuminate\Support\Facades\Route; Route::post('logout', [AuthController::class, 'logout']); -Route::post('refresh', [AuthController::class, 'refresh']); - Route::get('me', [AuthController::class, 'me']); Route::put('profile', [AuthController::class, 'updateProfile']); diff --git a/routes/api/admin/infrastructure.php b/routes/api/admin/infrastructure.php index 7afcbff..bf8df08 100644 --- a/routes/api/admin/infrastructure.php +++ b/routes/api/admin/infrastructure.php @@ -102,6 +102,7 @@ Route::prefix('ip-pool') ->controller(IpPoolController::class) ->group(function () { Route::get('networks', 'networks')->middleware('perm:module-vcenter.view'); + Route::get('dashboard', 'dashboard')->middleware('perm:module-vcenter.view'); Route::get('used-services', 'usedServices')->middleware('perm:module-vcenter.view'); Route::get('/', 'index')->middleware('perm:module-vcenter.view'); Route::post('/', 'store')->middleware('perm:module-vcenter.manager'); @@ -114,6 +115,7 @@ Route::prefix('ip-pool') Route::post('allocate', 'allocate')->middleware('perm:module-vcenter.manager'); Route::post('revocation/preview', 'revocationPreview')->middleware('perm:module-vcenter.manager'); Route::post('revoke', 'revoke')->middleware('perm:module-vcenter.manager'); + Route::get('logs', 'allLogs')->middleware('perm:module-vcenter.view'); Route::get('{id}/logs', 'logs')->middleware('perm:module-vcenter.view'); Route::get('{id}', 'show')->middleware('perm:module-vcenter.view'); Route::put('{id}', 'update')->middleware('perm:module-vcenter.manager'); diff --git a/routes/api/admin/system.php b/routes/api/admin/system.php index 7f51abc..37780d0 100644 --- a/routes/api/admin/system.php +++ b/routes/api/admin/system.php @@ -18,6 +18,7 @@ Route::prefix('settings') ->controller(AdminSettingController::class) ->middleware('perm:setting.manager') ->group(function () { + Route::post('verify-password', 'verifyPassword')->middleware('throttle:10,1'); Route::get('/', 'index'); Route::put('/', 'update'); Route::post('test-telegram', 'testTelegram'); @@ -112,6 +113,8 @@ Route::prefix('tickets') ->controller(AdminTicketController::class) ->group(function () { Route::get('/', 'index')->middleware('perm:support.view'); + Route::get('pending-count', 'pendingCount')->middleware('perm:support.view'); + Route::get('export', 'export')->middleware('perm:support.view'); Route::get('{ticket_num}', 'show')->middleware('perm:support.view'); Route::patch('{ticket_num}/status', 'updateStatus')->middleware('perm:support.edit'); Route::post('{ticket_num}/reply', 'reply')->middleware('perm:support.edit'); diff --git a/routes/client.php b/routes/client.php index 6d1f674..16ea1cf 100644 --- a/routes/client.php +++ b/routes/client.php @@ -159,6 +159,7 @@ Route::prefix('v1') Route::get('/', 'index')->name('index'); Route::post('/', 'store')->name('store'); Route::get('{ticket_num}', 'show')->name('show'); + Route::get('{ticket_num}/images/{index}', 'image')->whereNumber('index')->name('image'); Route::post('{ticket_num}/reply', 'reply')->middleware('throttle:10,1')->name('reply'); Route::patch('{ticket_num}/close', 'close')->name('close'); }); diff --git a/storage/app/public/tickets/0YKMrhhunXpj5XyfZ7MnkUe7fTqHZV5eRvULOc8G.webp b/storage/app/public/tickets/0YKMrhhunXpj5XyfZ7MnkUe7fTqHZV5eRvULOc8G.webp new file mode 100644 index 0000000..3a9d629 Binary files /dev/null and b/storage/app/public/tickets/0YKMrhhunXpj5XyfZ7MnkUe7fTqHZV5eRvULOc8G.webp differ diff --git a/storage/app/ticket-attachments/734/nRSWimhBJKSViIMwwIJ18iR74eQ8aYVoiqmfq0Cu.png b/storage/app/ticket-attachments/734/nRSWimhBJKSViIMwwIJ18iR74eQ8aYVoiqmfq0Cu.png new file mode 100644 index 0000000..ffadc6f Binary files /dev/null and b/storage/app/ticket-attachments/734/nRSWimhBJKSViIMwwIJ18iR74eQ8aYVoiqmfq0Cu.png differ diff --git a/tests/Feature/Api/Admin/IpPool/IpAllocationTest.php b/tests/Feature/Api/Admin/IpPool/IpAllocationTest.php index fb959b1..0171d7d 100644 --- a/tests/Feature/Api/Admin/IpPool/IpAllocationTest.php +++ b/tests/Feature/Api/Admin/IpPool/IpAllocationTest.php @@ -11,6 +11,7 @@ use App\Models\IpPoolLog; use App\Models\Mail as MailLog; use App\Models\ModuleIpAddress; use App\Models\ModuleNetwork; +use App\Models\Template; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; @@ -74,6 +75,8 @@ class IpAllocationTest extends TestCase public function test_allocate_assigns_ips_logs_and_emails_customer(): void { Mail::fake(); + $template = Template::where('scenario', IpAllocationMail::ALLOCATED)->sole(); + $template->update(['metadata' => [...$template->metadata, 'mail' => ['cc' => ['sale@tgs.com.vn'], 'bcc' => ['audit@tgs.com.vn']]]]); $first = $this->pool('10.0.0.5'); $second = $this->pool('10.0.0.6'); @@ -92,6 +95,7 @@ class IpAllocationTest extends TestCase $log = IpPoolLog::where('ip_pool_id', $first->id)->sole(); $this->assertSame([IpPoolLog::ALLOCATED, 'free', 'assigned', $this->admin->id], [$log->action, $log->status_from, $log->status_to, $log->user_id]); $this->assertSame(['2026-10-15', 'Colo 1U'], [$log->effective_date->toDateString(), $log->service_name]); + $this->assertSame(['kythuat@tgs.com.vn, sale@tgs.com.vn', 'audit@tgs.com.vn'], [$log->email_cc, $log->email_bcc]); Mail::assertQueued(IpAllocationMail::class, fn (IpAllocationMail $mail) => $mail->type === IpAllocationMail::ALLOCATED && count($mail->ips) === 2 @@ -248,4 +252,46 @@ class IpAllocationTest extends TestCase ->assertJsonPath('data.0.action', IpPoolLog::ALLOCATED) ->assertJsonPath('data.0.customer_name', 'Công ty A'); } + + public function test_audit_log_lists_every_ip_with_filters(): void + { + IpPoolLog::record($this->pool('10.0.0.8'), IpPoolLog::ALLOCATED, 'free', ['customer_name' => 'Công ty A']); + IpPoolLog::record($this->pool('10.0.0.9'), IpPoolLog::REVOKED, 'assigned', ['customer_name' => 'Công ty B']); + + $this->actingAs($this->admin, 'admin') + ->getJson(self::BASE . '/logs') + ->assertOk() + ->assertJsonPath('data.total', 2) + ->assertJsonPath('data.items.0.ip_address', '10.0.0.9'); + + $this->actingAs($this->admin, 'admin') + ->getJson(self::BASE . '/logs?' . http_build_query(['action' => [IpPoolLog::ALLOCATED], 'filter' => 'Công ty'])) + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.ip_address', '10.0.0.8'); + } + + public function test_dashboard_aggregates_usage_and_money_per_block_and_customer(): void + { + $this->pool('10.0.0.5', ['ip_block' => 'A', 'status' => 'assigned', 'customer_id' => $this->customer->id, 'customer_name' => 'Công ty A', 'monthly_cost' => 100, 'selling_price' => 300]); + $this->pool('10.0.0.6', ['ip_block' => 'A', 'ip_role' => 'ADDON', 'status' => 'assigned', 'customer_name' => 'Khách import', 'selling_price' => 50]); + $this->pool('10.0.0.7', ['ip_block' => 'A', 'monthly_cost' => 40, 'selling_price' => 999]); + $this->pool('10.0.0.8', ['ip_block' => 'B', 'status' => 'reserved']); + + $response = $this->actingAs($this->admin, 'admin') + ->getJson(self::BASE . '/dashboard') + ->assertOk() + ->assertJsonPath('data.summary.total', 4) + ->assertJsonPath('data.summary.primary', 3) + ->assertJsonPath('data.summary.addon', 1) + ->assertJsonPath('data.summary.assigned', 2) + ->assertJsonPath('data.summary.reserved', 1) + ->assertJsonPath('data.blocks.0.ip_block', 'A') + ->assertJsonPath('data.blocks.0.assigned', 2) + ->assertJsonPath('data.top_customers.0.customer_uuid', $this->customer->uuid); + + $this->assertEquals([140, 350, 210], [$response->json('data.summary.cost'), $response->json('data.summary.revenue'), $response->json('data.summary.profit')]); + $this->assertEqualsWithDelta(2 / 3, $response->json('data.blocks.0.usage_rate'), 0.0001); + $this->assertSame(['Công ty A', 'Khách import'], array_column($response->json('data.top_customers'), 'customer_name')); + } } diff --git a/tests/Feature/Api/Admin/PermissionGatingTest.php b/tests/Feature/Api/Admin/PermissionGatingTest.php index 9172152..a8fd8a8 100644 --- a/tests/Feature/Api/Admin/PermissionGatingTest.php +++ b/tests/Feature/Api/Admin/PermissionGatingTest.php @@ -61,6 +61,7 @@ class PermissionGatingTest extends TestCase 'domain-pricing index' => ['GET', '/api/v1/administrator/domain-pricing', 'setting.manager'], 'email-templates index' => ['GET', '/api/v1/administrator/email-templates', 'email-template.manager'], 'tickets index' => ['GET', '/api/v1/administrator/tickets', 'support.view'], + 'tickets pending count' => ['GET', '/api/v1/administrator/tickets/pending-count', 'support.view'], 'activity-logs index' => ['GET', '/api/v1/administrator/activity-logs', 'system-log.manager'], 'email-logs index' => ['GET', '/api/v1/administrator/email-logs', 'system-log.manager'], 'post-categories index' => ['GET', '/api/v1/administrator/post-categories', 'post.view'], diff --git a/tests/Feature/Api/Admin/RememberLoginTest.php b/tests/Feature/Api/Admin/RememberLoginTest.php new file mode 100644 index 0000000..d2dba8b --- /dev/null +++ b/tests/Feature/Api/Admin/RememberLoginTest.php @@ -0,0 +1,153 @@ +admin = User::factory()->create([ + 'username' => 'remember-admin', + 'password' => Hash::make('Password123!'), + ]); + } + + public function test_login_only_returns_refresh_token_when_remember_is_enabled(): void + { + $credentials = [ + 'username' => $this->admin->username, + 'password' => 'Password123!', + ]; + + $this->postJson('/api/v1/administrator/login', $credentials) + ->assertOk() + ->assertJsonMissingPath('data.refresh_token') + ->assertCookieExpired(self::REFRESH_COOKIE); + + $response = $this->postJson('/api/v1/administrator/login', [...$credentials, 'remember' => true]) + ->assertOk() + ->assertJsonPath('data.refresh_expires_in', 2592000) + ->assertJsonMissingPath('data.refresh_token') + ->assertCookie(self::REFRESH_COOKIE) + ->assertCookieNotExpired(self::REFRESH_COOKIE); + + $cookie = $response->getCookie(self::REFRESH_COOKIE, false); + + $this->assertTrue($cookie->isHttpOnly()); + $this->assertTrue($cookie->isSecure()); + $this->assertSame('strict', $cookie->getSameSite()); + $this->assertSame('/api/v1/administrator', $cookie->getPath()); + $this->assertNotSame($response->json('data.token'), $cookie->getValue()); + $this->assertNull(JWTAuth::setToken($response->json('data.token'))->getPayload()->get('remember_until')); + } + + public function test_remembered_session_can_refresh_without_a_valid_access_token(): void + { + $login = $this->postJson('/api/v1/administrator/login', [ + 'username' => $this->admin->username, + 'password' => 'Password123!', + 'remember' => true, + ])->assertOk(); + + $refreshToken = $login->getCookie(self::REFRESH_COOKIE, false)->getValue(); + + $this->withCredentials() + ->withUnencryptedCookie(self::REFRESH_COOKIE, $refreshToken) + ->postJson('/api/v1/administrator/refresh', [], $this->refreshHeaders()) + ->assertOk() + ->assertJsonMissingPath('data.refresh_token') + ->assertJsonStructure(['data' => ['token', 'expires_in']]) + ->assertCookie(self::REFRESH_COOKIE); + } + + public function test_remembered_session_can_refresh_before_thirty_days(): void + { + $login = $this->postJson('/api/v1/administrator/login', [ + 'username' => $this->admin->username, + 'password' => 'Password123!', + 'remember' => true, + ])->assertOk(); + + $this->travelTo(now()->addDays(29)); + + $this->withCredentials() + ->withUnencryptedCookie( + self::REFRESH_COOKIE, + $login->getCookie(self::REFRESH_COOKIE, false)->getValue(), + )->postJson('/api/v1/administrator/refresh', [], $this->refreshHeaders())->assertOk(); + } + + public function test_remembered_session_cannot_refresh_after_thirty_days(): void + { + $login = $this->postJson('/api/v1/administrator/login', [ + 'username' => $this->admin->username, + 'password' => 'Password123!', + 'remember' => true, + ])->assertOk(); + + $this->travelTo(now()->addDays(30)->addSecond()); + + $this->withCredentials() + ->withUnencryptedCookie( + self::REFRESH_COOKIE, + $login->getCookie(self::REFRESH_COOKIE, false)->getValue(), + )->postJson('/api/v1/administrator/refresh', [], $this->refreshHeaders()) + ->assertUnauthorized() + ->assertCookieExpired(self::REFRESH_COOKIE); + } + + public function test_refresh_rejects_body_tokens_and_non_ajax_requests(): void + { + $this->postJson('/api/v1/administrator/refresh', [ + 'refresh_token' => 'exposed-token', + ])->assertForbidden(); + + $this->postJson('/api/v1/administrator/refresh', [ + 'refresh_token' => 'exposed-token', + ], $this->refreshHeaders())->assertUnprocessable(); + } + + public function test_logout_revokes_the_http_only_refresh_token(): void + { + $login = $this->postJson('/api/v1/administrator/login', [ + 'username' => $this->admin->username, + 'password' => 'Password123!', + 'remember' => true, + ])->assertOk(); + + $refreshToken = $login->getCookie(self::REFRESH_COOKIE, false)->getValue(); + + $this->assertTrue(AdminRefreshToken::where('token', hash('sha256', $refreshToken))->exists()); + + $this->withCredentials() + ->withToken($login->json('data.token')) + ->withUnencryptedCookie(self::REFRESH_COOKIE, $refreshToken) + ->postJson('/api/v1/administrator/logout') + ->assertOk() + ->assertCookieExpired(self::REFRESH_COOKIE); + + $this->assertFalse(AdminRefreshToken::where('token', hash('sha256', $refreshToken))->exists()); + } + + private function refreshHeaders(): array + { + return ['X-Requested-With' => 'XMLHttpRequest']; + } +} diff --git a/tests/Feature/Api/Admin/Setting/SystemConfigPasswordVerificationTest.php b/tests/Feature/Api/Admin/Setting/SystemConfigPasswordVerificationTest.php new file mode 100644 index 0000000..f33d5e1 --- /dev/null +++ b/tests/Feature/Api/Admin/Setting/SystemConfigPasswordVerificationTest.php @@ -0,0 +1,49 @@ + 'setting.manager', 'guard_name' => 'admin']); + $role = Role::create(['name' => 'settings-manager', 'guard_name' => 'admin']); + $role->givePermissionTo($permission); + + $admin = User::factory()->create(['password' => Hash::make('CurrentPassword1')]); + $admin->assignRole($role); + + return $admin; + } + + public function test_admin_can_verify_their_current_password(): void + { + $this->actingAs($this->admin(), 'admin') + ->postJson('/api/v1/administrator/settings/verify-password', [ + 'password' => 'CurrentPassword1', + ]) + ->assertOk() + ->assertJsonPath('error', false); + } + + public function test_incorrect_password_is_rejected(): void + { + $this->actingAs($this->admin(), 'admin') + ->postJson('/api/v1/administrator/settings/verify-password', [ + 'password' => 'WrongPassword', + ]) + ->assertUnprocessable() + ->assertJsonPath('message', 'Mật khẩu hiện tại không chính xác.'); + } +} diff --git a/tests/Feature/Api/Admin/Ticket/AdminTicketTest.php b/tests/Feature/Api/Admin/Ticket/AdminTicketTest.php new file mode 100644 index 0000000..ee6b003 --- /dev/null +++ b/tests/Feature/Api/Admin/Ticket/AdminTicketTest.php @@ -0,0 +1,108 @@ +customer = Customer::factory()->create(['fullname' => 'Nguyễn Văn A']); + + $role = Role::create(['name' => 'support', 'guard_name' => 'admin']); + foreach (['support.view', 'support.edit'] as $permission) { + $role->givePermissionTo(Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'admin'])); + } + $this->admin = User::factory()->create(['username' => 'hotro']); + $this->admin->assignRole($role); + } + + private function makeTicket(string $status, array $attributes = []): Ticket + { + return Ticket::create(array_merge([ + 'uuid' => (string) Str::uuid(), + 'ticket_num' => 'TKT-' . Str::upper(Str::random(6)), + 'subject' => 'Subject', + 'description' => 'Description', + 'status' => $status, + 'room' => 'technical', + 'customer_id' => $this->customer->id, + ], $attributes)); + } + + public function test_counts_only_open_and_pending_tickets(): void + { + foreach (['open', 'open', 'pending', 'resolved', 'closed'] as $status) { + $this->makeTicket($status); + } + + $this->actingAs($this->admin, 'admin') + ->getJson('/api/v1/administrator/tickets/pending-count') + ->assertOk() + ->assertJsonPath('data.count', 3); + } + + public function test_detail_returns_status_history_with_causer(): void + { + $ticket = $this->makeTicket('open'); + + $this->actingAs($this->admin, 'admin') + ->patchJson("/api/v1/administrator/tickets/{$ticket->ticket_num}/status", ['status' => 'pending'])->assertOk(); + $this->actingAs($this->admin, 'admin') + ->patchJson("/api/v1/administrator/tickets/{$ticket->ticket_num}/status", ['status' => 'resolved'])->assertOk(); + + $history = $this->actingAs($this->admin, 'admin') + ->getJson("/api/v1/administrator/tickets/{$ticket->ticket_num}") + ->assertOk() + ->json('data.status_history'); + + $this->assertSame( + [['pending', 'resolved'], ['open', 'pending'], [null, 'open']], + array_map(fn ($log) => [$log['from'], $log['to']], $history), + ); + $this->assertSame('hotro', $history[0]['causer_name']); + $this->assertSame('admin', $history[0]['causer_type']); + } + + public function test_exports_filtered_tickets_to_branded_excel(): void + { + $this->makeTicket('open', ['subject' => 'VPS không vào được', 'ticket_num' => 'TKT-EXPORT', 'data' => json_encode(['priority' => 'high'])]); + $this->makeTicket('closed', ['subject' => 'Ticket khác']); + + $response = $this->actingAs($this->admin, 'admin') + ->get('/api/v1/administrator/tickets/export?' . http_build_query(['filter' => 'vps', 'status' => ['open']])) + ->assertOk(); + + $sheet = IOFactory::load($response->baseResponse->getFile()->getPathname())->getActiveSheet(); + + $this->assertSame('DANH SÁCH TICKET HỖ TRỢ', $sheet->getCell('A6')->getValue()); + $this->assertSame('Mã ticket', $sheet->getCell('A7')->getValue()); + $this->assertSame( + ['TKT-EXPORT', 'VPS không vào được', 'Nguyễn Văn A', $this->customer->email, 'Kỹ thuật', 'Cao', 'Mở', 0], + array_slice($sheet->rangeToArray('A8:J8', null, true, false)[0], 0, 8), + ); + $this->assertNull($sheet->getCell('A9')->getValue()); + + $this->actingAs(User::factory()->create(), 'admin') + ->getJson('/api/v1/administrator/tickets/export') + ->assertForbidden(); + } +} diff --git a/tests/Feature/Api/Client/ClientAuthSecurityTest.php b/tests/Feature/Api/Client/ClientAuthSecurityTest.php index 4c0aa56..3f85515 100644 --- a/tests/Feature/Api/Client/ClientAuthSecurityTest.php +++ b/tests/Feature/Api/Client/ClientAuthSecurityTest.php @@ -25,6 +25,22 @@ class ClientAuthSecurityTest extends TestCase return $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()]); } + public function test_client_routes_require_a_valid_encrypted_application_token(): void + { + $this->getJson('/v1/posts/featured') + ->assertForbidden() + ->assertJsonPath('message', 'Invalid access token'); + + $this->withHeaders(['x-access-token' => 'forged-token']) + ->getJson('/v1/posts/featured') + ->assertForbidden() + ->assertJsonPath('message', 'Invalid access token'); + + $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->getJson('/v1/posts/featured') + ->assertOk(); + } + public function test_access_token_older_than_skew_window_is_rejected(): void { $this->withHeaders(['x-access-token' => $this->buildClientAccessToken(time() - 3600)]) diff --git a/tests/Feature/Api/Client/ClientTicketTest.php b/tests/Feature/Api/Client/ClientTicketTest.php new file mode 100644 index 0000000..68467d9 --- /dev/null +++ b/tests/Feature/Api/Client/ClientTicketTest.php @@ -0,0 +1,170 @@ +customer = Customer::factory()->create(['status' => 1]); + } + + private function makeTicket(Customer $owner, array $attributes = []): Ticket + { + return Ticket::create(array_merge([ + 'uuid' => (string) Str::uuid(), + 'ticket_num' => 'TKT-' . Str::upper(Str::random(6)), + 'subject' => 'Subject', + 'description' => 'Description', + 'status' => 'open', + 'room' => 'technical', + 'customer_id' => $owner->id, + ], $attributes)); + } + + private function as(Customer $customer): static + { + return $this->actingAs($customer, 'client')->withHeaders(['x-access-token' => $this->buildClientAccessToken()]); + } + + private function list(array $query = []) + { + return $this->as($this->customer)->getJson('/v1/client/tickets?' . http_build_query($query)); + } + + public function test_paginates_with_requested_page_size(): void + { + foreach (range(1, 12) as $i) { + $this->makeTicket($this->customer, ['subject' => "Ticket {$i}"]); + } + $this->makeTicket(Customer::factory()->create(['status' => 1])); + + $response = $this->list(['per_page' => 5, 'page' => 3])->assertOk(); + + $this->assertSame(12, $response->json('data.total')); + $this->assertSame(3, $response->json('data.last_page')); + $this->assertCount(2, $response->json('data.items')); + } + + public function test_filters_by_search_room_and_status(): void + { + $this->makeTicket($this->customer, ['subject' => 'Lỗi VPS không khởi động', 'ticket_num' => 'TKT-VPS']); + $this->makeTicket($this->customer, ['subject' => 'Báo giá hosting', 'room' => 'sales']); + $this->makeTicket($this->customer, ['subject' => 'VPS chậm', 'status' => 'closed']); + $this->makeTicket($this->customer, ['subject' => '100% CPU']); + + $this->assertSame(['TKT-VPS'], collect($this->list(['search' => 'khởi động'])->json('data.items'))->pluck('ticket_num')->all()); + $this->assertCount(1, $this->list(['search' => 'tkt-vps'])->json('data.items')); + $this->assertCount(1, $this->list(['search' => '100%'])->json('data.items')); + $this->assertCount(1, $this->list(['room' => 'sales'])->json('data.items')); + $this->assertCount(1, $this->list(['search' => 'VPS', 'status' => 'open'])->json('data.items')); + } + + public function test_rejects_oversized_page(): void + { + $this->list(['per_page' => 500])->assertStatus(422); + } + + public function test_client_payload_hides_internal_ids_and_storage_urls(): void + { + Storage::fake('public'); + Storage::disk('public')->put('tickets/legacy.webp', 'legacy'); + $ticket = $this->makeTicket($this->customer, [ + 'ticket_num' => 'TKT-LEGACY', + 'data' => json_encode(['priority' => 'high']), + 'gallery' => ['https://api.portal.test/storage/tickets/legacy.webp'], + ]); + TicketReply::create(['ticket_id' => $ticket->id, 'message' => 'Hi', 'user_id' => User::factory()->create()->id]); + + $data = $this->as($this->customer)->getJson('/v1/client/tickets/TKT-LEGACY')->assertOk()->json('data'); + + foreach (['id', 'uuid', 'customer_id', 'data'] as $key) { + $this->assertArrayNotHasKey($key, $data); + } + $this->assertSame(['fullname'], array_keys($data['customer'])); + $this->assertSame('high', $data['priority']); + $this->assertSame(['/v1/client/tickets/TKT-LEGACY/images/0'], $data['gallery']); + $this->assertSame(['message', 'sender_type', 'sender_name', 'sender_role', 'created_at'], array_keys($data['replies'][0])); + $this->assertStringNotContainsString('storage', json_encode($data)); + + $this->as($this->customer)->get('/v1/client/tickets/TKT-LEGACY/images/0')->assertOk(); + $this->as($this->customer)->get('/v1/client/tickets/TKT-LEGACY/images/1')->assertNotFound(); + } + + public function test_uploaded_images_are_private_and_owner_only(): void + { + Storage::fake('local'); + Mail::fake(); + Queue::fake(); + + $ticketNum = $this->as($this->customer)->post('/v1/client/tickets', [ + 'subject' => 'Cần hỗ trợ VPS', + 'description' => 'Mô tả chi tiết vấn đề đang gặp phải', + 'images' => [UploadedFile::fake()->image('shot.png')], + ])->assertCreated()->json('data.ticket_num'); + + $path = Ticket::where('ticket_num', $ticketNum)->value('gallery')[0]; + $this->assertStringStartsWith("ticket-attachments/{$this->customer->id}/", $path); + Storage::disk('local')->assertExists($path); + + $this->as($this->customer)->get("/v1/client/tickets/{$ticketNum}/images/0")->assertOk(); + $this->as(Customer::factory()->create(['status' => 1]))->get("/v1/client/tickets/{$ticketNum}/images/0")->assertNotFound(); + + $this->get("/api/v1/administrator/tickets/{$ticketNum}/images/0")->assertForbidden(); + $signed = URL::temporarySignedRoute('admin.tickets.image', now()->addMinute(), ['ticket' => $ticketNum, 'index' => 0]); + $this->get($signed)->assertOk(); + } + + public function test_moves_legacy_public_images_to_private_disk(): void + { + Storage::fake('public'); + Storage::fake('local'); + Storage::disk('public')->put('tickets/old.webp', 'old'); + $ticket = $this->makeTicket($this->customer, ['gallery' => ['https://api.portal.test/storage/tickets/old.webp']]); + + $this->assertTrue(TicketGallery::moveLegacyToPrivate($ticket)); + + $path = "ticket-attachments/{$this->customer->id}/old.webp"; + $this->assertSame([$path], $ticket->fresh()->gallery); + Storage::disk('local')->assertExists($path); + Storage::disk('public')->assertMissing('tickets/old.webp'); + $this->assertFalse(TicketGallery::moveLegacyToPrivate($ticket->fresh())); + } + + public function test_ticket_emails_show_customer_fullname_and_room_label(): void + { + $this->seed(\Database\Seeders\TemplateSeeder::class); + $this->customer->update(['fullname' => 'Nguyễn Văn A']); + $ticket = $this->makeTicket($this->customer, ['ticket_num' => 'TKT-MAIL']); + + foreach ([new TicketCreated($ticket, $this->customer), new TicketAdminNotification($ticket, $this->customer)] as $mail) { + $html = $mail->render(); + $this->assertStringContainsString('Nguyễn Văn A', $html, $mail::class); + $this->assertStringContainsString('Kỹ thuật', $html, $mail::class); + $this->assertStringNotContainsString('N/A', $html, $mail::class); + } + } +}