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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user