feat: harden admin auth and ticket workflows
- add rotating admin refresh tokens and password gates\n- expand ticket galleries, exports, and client resources\n- improve IP allocation auditing and dashboard data\n- enforce encrypted client access tokens on public routes
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class TicketsExport implements BrandedReport
|
||||
{
|
||||
public function __construct(private readonly Builder $query) {}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return 'Danh sách ticket hỗ trợ';
|
||||
}
|
||||
|
||||
public function query(): Builder
|
||||
{
|
||||
return $this->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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Api\Client\Ticket;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class ClientTicketReplyResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$fromStaff = $this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Api\Client\Ticket;
|
||||
|
||||
use App\Support\TicketGallery;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class ClientTicketResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'ticket_num' => $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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,',','.'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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ự)
|
||||
|
||||
@@ -261,7 +261,7 @@ class AssignIpToGuestJob implements ShouldQueue
|
||||
"🖥️ VM ID: <code>{$this->vmId}</code>\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" .
|
||||
|
||||
@@ -237,7 +237,7 @@ class UpdateVmConfigJob implements ShouldQueue
|
||||
"━━━━━━━━━━━━━━━━━━━━\n" .
|
||||
"🖥️ VM ID: <code>{$this->vmId}</code>\n" .
|
||||
"📦 Server: <code>" . ($vm?->name ?? 'N/A') . "</code>\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: <code>" . htmlspecialchars($e->getMessage()) . "</code>\n" .
|
||||
"⚡ Trạng thái: VM đã bị đánh dấu inactive\n" .
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ class InvoiceNotificationMail extends TemplateMailable
|
||||
}
|
||||
|
||||
return '<table style="width: 100%; border-collapse: collapse; margin-top: 16px;" role="presentation" cellspacing="0" cellpadding="0"><tbody><tr>'
|
||||
. '<td style="padding: 14px 16px; border-left: 3px solid #f16723; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #626262;">'
|
||||
. '<td style="padding: 14px 16px; border-left: 3px solid #f16723; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #1a1a1a;">'
|
||||
. '<strong style="color: #374151;">Ghi chú:</strong> '
|
||||
. htmlspecialchars((string) $this->invoice->note, ENT_QUOTES, 'UTF-8')
|
||||
. '</td></tr></tbody></table>';
|
||||
|
||||
@@ -107,7 +107,7 @@ class IpAllocationMail extends TemplateMailable
|
||||
->map(fn (string $title) => '<td style="' . self::HEAD_CELL . '">' . $title . '</td>')
|
||||
->implode('');
|
||||
|
||||
return '<table style="border: 1px solid #f6d2b8; border-radius: 10px; border-collapse: separate; overflow: hidden;" role="presentation" width="100%" cellspacing="0" cellpadding="0"><tbody>'
|
||||
return '<table style="' . EmailTemplateShell::TABLE_STYLE . '" role="presentation" width="100%" cellspacing="0" cellpadding="0"><tbody>'
|
||||
. '<tr>' . $head . '</tr>' . $rows
|
||||
. '</tbody></table>';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AdminRefreshToken extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'token',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<int, array{from: ?string, to: string, causer_type: ?string, causer_name: ?string, created_at: mixed}>
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Api\Admin;
|
||||
|
||||
use App\Models\AdminRefreshToken;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tymon\JWTAuth\Facades\JWTAuth;
|
||||
|
||||
class AdminRefreshTokenService
|
||||
{
|
||||
public function create(int $userId, CarbonInterface $expiresAt): string
|
||||
{
|
||||
$rawToken = Str::random(64);
|
||||
|
||||
AdminRefreshToken::create([
|
||||
'user_id' => $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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\IpPool;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\IpPool;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class IpPoolDashboard
|
||||
{
|
||||
public const TOP_CUSTOMERS = 15;
|
||||
|
||||
private const ADDON_ROLE = 'ADDON';
|
||||
|
||||
public function build(): array
|
||||
{
|
||||
$blocks = IpPool::query()
|
||||
->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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 '<tr class="NoiDungKM"><td style="box-sizing: border-box; width: 700px; padding: 32px 48px; background-color: #ffffff; font-family: Montserrat,-apple-system,sans-serif; font-size: 15px; line-height: 24px; color: #626262;" valign="top" bgcolor="#ffffff" width="700">'
|
||||
return '<tr class="NoiDungKM"><td style="box-sizing: border-box; width: 700px; padding: 32px 48px; background-color: #ffffff; font-family: Montserrat,-apple-system,sans-serif; font-size: 15px; line-height: 24px; color: #1a1a1a;" valign="top" bgcolor="#ffffff" width="700">'
|
||||
. $innerHtml
|
||||
. '</td></tr>';
|
||||
}
|
||||
@@ -74,7 +80,7 @@ class EmailTemplateShell
|
||||
public static function note(string $html, string $label = 'Lưu ý:'): string
|
||||
{
|
||||
return '<table style="box-sizing: border-box; width: 100%; border-collapse: collapse; margin: 0 0 20px 0;" role="presentation" width="100%" cellspacing="0" cellpadding="0"><tbody><tr>'
|
||||
. '<td style="box-sizing: border-box; padding: 14px 16px; border-left: 3px solid ' . self::BRAND . '; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #626262;" bgcolor="#f8fafc">'
|
||||
. '<td style="box-sizing: border-box; padding: 14px 16px; border-left: 3px solid ' . self::BRAND . '; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #1a1a1a;" bgcolor="#f8fafc">'
|
||||
. '<strong style="box-sizing: border-box; color: #374151;">' . $label . '</strong> ' . $html
|
||||
. '</td></tr></tbody></table>';
|
||||
}
|
||||
@@ -99,15 +105,15 @@ class EmailTemplateShell
|
||||
$html .= self::row($label, $value);
|
||||
}
|
||||
|
||||
return '<table style="box-sizing: border-box; width: 100%; border-collapse: collapse; table-layout: fixed; margin: 0 0 20px 0; border: 1px solid #e8e8e8;" role="presentation" width="100%" cellspacing="0" cellpadding="0"><tbody>'
|
||||
return '<table style="' . self::TABLE_STYLE . ' table-layout: fixed; margin: 0 0 20px 0;" role="presentation" width="100%" cellspacing="0" cellpadding="0"><tbody>'
|
||||
. $html . $extraRowsHtml
|
||||
. '</tbody></table>';
|
||||
}
|
||||
|
||||
public static function row(string $label, string $valueHtml): string
|
||||
{
|
||||
return '<tr><td style="box-sizing: border-box; width: 38%; padding: 8px 12px; border-bottom: 1px solid #e8e8e8; background-color: #fafafa; color: #626262;" bgcolor="#fafafa">' . $label . '</td>'
|
||||
. '<td style="box-sizing: border-box; padding: 8px 12px; border-bottom: 1px solid #e8e8e8; color: #374151;"><strong>' . $valueHtml . '</strong></td></tr>';
|
||||
return '<tr><td style="' . self::HEAD_CELL_STYLE . ' width: 38%; letter-spacing: 0; font-weight: 600;" bgcolor="#fff5ee">' . $label . '</td>'
|
||||
. '<td style="box-sizing: border-box; padding: 11px 14px; border-bottom: 1px solid ' . self::TABLE_BORDER . '; color: #374151;"><strong>' . $valueHtml . '</strong></td></tr>';
|
||||
}
|
||||
|
||||
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('<strong style="box-sizing: border-box; font-size: 14px; line-height: 22px; color: #ffffff;">CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ</strong>')
|
||||
. $line('<span style="' . $text . '">Địa chỉ: 128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh</span>')
|
||||
. $line('<span style="' . $text . '">Tel: (028) 7309. 7379 - MST: 0318443116</span>')
|
||||
. $line('<span style="' . $text . '">Email: <a style="box-sizing: border-box; color: #ffffff; text-decoration: none;" href="mailto:support@tgs.com.vn">support@tgs.com.vn</a></span>')
|
||||
. $line('<span style="box-sizing: border-box; font-size: 12px; line-height: 20px; color: #ffffff; font-weight: bold;">🥇 Thế Giới Số 🥇 - Premium HOSTING • CLOUD • SERVER & WEBSITE | 15+ năm Phụng Sự🚀</span>', '6px 30px 16px 30px');
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
final class TicketGallery
|
||||
{
|
||||
private const DISK = 'local';
|
||||
|
||||
private const LEGACY_DISK = 'public';
|
||||
|
||||
private const LEGACY_URL_SEGMENT = '/storage/';
|
||||
|
||||
/**
|
||||
* @param UploadedFile[] $files
|
||||
* @return string[]
|
||||
*/
|
||||
public static function store(array $files, int $customerId): array
|
||||
{
|
||||
return array_map(
|
||||
fn (UploadedFile $file) => $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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user