Files
portal-api/app/Http/Controllers/Api/Admin/Ticket/TicketController.php
T
baodev0 0b08689ea4 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
2026-10-16 17:00:55 +07:00

199 lines
6.7 KiB
PHP

<?php
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;
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
{
public function __construct(
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)
{
$query = $this->filteredQuery($request->validate(self::FILTER_RULES))->with(['customer']);
$paginated = $query->paginate($request->input('per_page', 15));
return response()->json([
'error' => false,
'data' => [
'items' => TicketResource::collection($paginated->items()),
'total' => $paginated->total(),
'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
],
]);
}
/**
* Show a ticket with its replies and customer info.
*/
public function 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()
->with(['reply.user', 'customer'])
->where('ticket_num', $ticket_num)
->first();
if (!$ticket) {
return response()->json([
'error' => true,
'message' => 'Ticket không tồn tại.',
], 404);
}
return response()->json([
'error' => false,
'data' => [...(new TicketResource($ticket))->resolve(), 'status_history' => $ticket->statusHistory()],
]);
}
/**
* Update ticket status.
*/
public function updateStatus(Request $request, string $ticket_num)
{
$request->validate([
'status' => ['required', 'string', 'in:open,pending,resolved,closed'],
]);
$ticket = Ticket::query()->where('ticket_num', $ticket_num)->first();
if (!$ticket) {
return response()->json([
'error' => true,
'message' => 'Ticket không tồn tại.',
], 404);
}
$ticket->update(['status' => $request->input('status')]);
return response()->json([
'error' => false,
'message' => 'Trạng thái ticket đã được cập nhật.',
'data' => new TicketResource($ticket->fresh()),
]);
}
/**
* Admin replies to a ticket.
*/
public function reply(Request $request, string $ticket_num)
{
$request->validate([
'message' => ['required', 'string'],
]);
$admin = auth('admin')->user();
$ticket = Ticket::query()->where('ticket_num', $ticket_num)->first();
if (!$ticket) {
return response()->json([
'error' => true,
'message' => 'Ticket không tồn tại.',
], 404);
}
$reply = $this->ticketReplyRepository->create([
'ticket_id' => $ticket->id,
'user_id' => $admin->id,
'message' => $request->input('message'),
'customer_id' => $ticket->customer_id,
]);
Notification::notifyCustomer($ticket->customer, [
'type' => 'ticket_reply',
'title' => 'Admin vừa phản hồi ticket của bạn',
'body' => $ticket->ticket_num . ' - ' . $ticket->subject,
'link' => '/tickets/' . $ticket->ticket_num,
'data' => [
'ticket_num' => $ticket->ticket_num,
'reply_id' => $reply->id,
],
]);
// Update ticket status to pending after admin reply
if ($ticket->status === 'open') {
$ticket->update(['status' => 'pending']);
}
$reply->load('user');
broadcast(new TicketReplyCreated(
ticketNum: $ticket->ticket_num,
customerId: $ticket->customer_id,
replyId: $reply->id,
));
return response()->json([
'error' => false,
'message' => 'Phản hồi đã được gửi.',
'data' => new TicketReplyResource($reply),
], 201);
}
public function image(string $ticket, int $index)
{
return TicketGallery::response(Ticket::where('ticket_num', $ticket)->firstOrFail(), $index);
}
}