- VmProvisionException/VmProvisionRefund xử lý lỗi cấp phát VM - QueueJobInspector + QueueMonitorController xem chi tiết job - Thêm service uuid cho ticket, thời lượng cho activity_logs - Migration chuẩn hoá RAM addon sang MB, trạng thái deleting cho snapshot Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
269 lines
9.1 KiB
PHP
269 lines
9.1 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\Client\Ticket;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
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;
|
|
use App\Models\Service;
|
|
use App\Models\Setting;
|
|
use App\Models\Ticket;
|
|
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\Str;
|
|
|
|
class TicketController extends Controller
|
|
{
|
|
public function __construct(
|
|
protected TicketRepositoryInterface $ticketRepository,
|
|
protected TicketReplyRepositoryInterface $ticketReplyRepository,
|
|
) {}
|
|
|
|
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');
|
|
|
|
foreach (['status', 'room'] as $column) {
|
|
if (filled($filters[$column] ?? null)) {
|
|
$query->where($column, $filters[$column]);
|
|
}
|
|
}
|
|
|
|
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' => ClientTicketResource::collection($paginated->items()),
|
|
'total' => $paginated->total(),
|
|
'per_page' => $paginated->perPage(),
|
|
'current_page' => $paginated->currentPage(),
|
|
'last_page' => $paginated->lastPage(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Create a new ticket for the authenticated customer.
|
|
*/
|
|
private function assertServiceTicketable(string $serviceUuid, int $customerId): void
|
|
{
|
|
$failed = Service::where('uuid', $serviceUuid)->where('customer_id', $customerId)->where('status', 'failed')->exists();
|
|
abort_unless($failed, 404, 'Dịch vụ không tồn tại.');
|
|
|
|
$hasOpenTicket = Ticket::where('service_uuid', $serviceUuid)->whereIn('status', Ticket::UNRESOLVED_STATUSES)->exists();
|
|
abort_if($hasOpenTicket, 422, 'Dịch vụ này đã có ticket đang được xử lý.');
|
|
}
|
|
|
|
public function store(Request $request)
|
|
{
|
|
$request->validate([
|
|
'subject' => ['required', 'string', 'min:5', 'max:200'],
|
|
'description' => ['required', 'string', 'min:20'],
|
|
'room' => ['nullable', 'string'],
|
|
'data' => ['nullable', 'array'],
|
|
'service_uuid' => ['nullable', 'uuid'],
|
|
'images' => ['nullable', 'array', 'max:5'],
|
|
'images.*' => ['image', 'mimes:jpg,jpeg,png,gif,webp', 'max:5120'],
|
|
]);
|
|
|
|
$customer = auth('client')->user();
|
|
$serviceUuid = $request->input('service_uuid');
|
|
|
|
if ($serviceUuid) {
|
|
$this->assertServiceTicketable($serviceUuid, $customer->id);
|
|
}
|
|
|
|
$ticketNum = 'TKT-' . now()->format('Ymd') . '-' . strtoupper(Str::random(5));
|
|
|
|
$gallery = TicketGallery::store($request->file('images', []), $customer->id);
|
|
|
|
$ticket = $this->ticketRepository->create([
|
|
'uuid' => (string) Str::uuid(),
|
|
'ticket_num' => $ticketNum,
|
|
'subject' => $request->input('subject'),
|
|
'description' => $request->input('description'),
|
|
'status' => 'open',
|
|
'room' => $request->input('room'),
|
|
'data' => $request->input('data') ? json_encode($request->input('data')) : null,
|
|
'gallery' => $gallery ?: null,
|
|
'customer_id' => $customer->id,
|
|
'service_uuid' => $serviceUuid,
|
|
]);
|
|
|
|
TicketJobs::dispatch($ticket, $customer);
|
|
Mail::to($customer->email)->queue(new TicketCreated($ticket, $customer));
|
|
|
|
$setting = Setting::where('key', 'setting')->first();
|
|
$adminEmail = $setting ? (json_decode((string) $setting->value)?->admin_email ?? null) : null;
|
|
if ($adminEmail) {
|
|
Mail::to($adminEmail)->queue(new TicketAdminNotification($ticket, $customer));
|
|
}
|
|
|
|
return response()->json([
|
|
'error' => false,
|
|
'message' => 'Ticket đã được khởi tạo.',
|
|
'data' => new ClientTicketResource($ticket),
|
|
], 201);
|
|
}
|
|
|
|
/**
|
|
* Show a single ticket with its replies (must belong to customer).
|
|
*/
|
|
public function show(string $ticket_num)
|
|
{
|
|
$customer = auth('client')->user();
|
|
|
|
$ticket = Ticket::query()
|
|
->with(['reply.user', 'customer'])
|
|
->where('ticket_num', $ticket_num)
|
|
->where('customer_id', $customer->id)
|
|
->first();
|
|
|
|
if (!$ticket) {
|
|
return response()->json([
|
|
'error' => true,
|
|
'message' => 'Ticket không tồn tại hoặc bạn không có quyền truy cập.',
|
|
], 404);
|
|
}
|
|
|
|
return response()->json([
|
|
'error' => false,
|
|
'data' => new ClientTicketResource($ticket),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Add a reply to a ticket (customer side).
|
|
*/
|
|
public function reply(Request $request, string $ticket_num)
|
|
{
|
|
$request->validate([
|
|
'message' => ['required', 'string'],
|
|
]);
|
|
|
|
$customer = auth('client')->user();
|
|
|
|
$ticket = Ticket::query()
|
|
->where('ticket_num', $ticket_num)
|
|
->where('customer_id', $customer->id)
|
|
->first();
|
|
|
|
if (!$ticket) {
|
|
return response()->json([
|
|
'error' => true,
|
|
'message' => 'Ticket không tồn tại hoặc bạn không có quyền truy cập.',
|
|
], 404);
|
|
}
|
|
|
|
if (in_array($ticket->status, ['closed', 'resolved'])) {
|
|
return response()->json([
|
|
'error' => true,
|
|
'message' => 'Ticket đã đóng, không thể thêm phản hồi.',
|
|
], 422);
|
|
}
|
|
|
|
$reply = $this->ticketReplyRepository->create([
|
|
'ticket_id' => $ticket->id,
|
|
'customer_id' => $customer->id,
|
|
'message' => $request->input('message'),
|
|
'user_id' => null,
|
|
]);
|
|
|
|
Notification::notifyAdmins([
|
|
'type' => 'ticket_reply',
|
|
'title' => 'Có phản hồi mới từ khách hàng',
|
|
'body' => $ticket->ticket_num . ' - ' . $ticket->subject,
|
|
'link' => '/tickets/' . $ticket->ticket_num,
|
|
'data' => [
|
|
'ticket_num' => $ticket->ticket_num,
|
|
'reply_id' => $reply->id,
|
|
],
|
|
]);
|
|
|
|
broadcast(new TicketReplyCreated(
|
|
ticketNum: $ticket->ticket_num,
|
|
customerId: $customer->id,
|
|
replyId: $reply->id,
|
|
));
|
|
|
|
// Update ticket status back to open (customer replied)
|
|
$ticket->update(['status' => 'open']);
|
|
|
|
return response()->json([
|
|
'error' => false,
|
|
'message' => 'Phản hồi đã được gửi.',
|
|
'data' => new ClientTicketReplyResource($reply),
|
|
], 201);
|
|
}
|
|
|
|
/**
|
|
* Customer closes their ticket.
|
|
*/
|
|
public function close(string $ticket_num)
|
|
{
|
|
$customer = auth('client')->user();
|
|
|
|
$ticket = Ticket::query()
|
|
->where('ticket_num', $ticket_num)
|
|
->where('customer_id', $customer->id)
|
|
->first();
|
|
|
|
if (!$ticket) {
|
|
return response()->json([
|
|
'error' => true,
|
|
'message' => 'Ticket không tồn tại hoặc bạn không có quyền truy cập.',
|
|
], 404);
|
|
}
|
|
|
|
if ($ticket->status === 'closed') {
|
|
return response()->json([
|
|
'error' => true,
|
|
'message' => 'Ticket đã được đóng trước đó.',
|
|
], 422);
|
|
}
|
|
|
|
$ticket->update(['status' => 'closed']);
|
|
|
|
return response()->json([
|
|
'error' => false,
|
|
'message' => 'Ticket đã được đóng.',
|
|
'data' => new 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);
|
|
}
|
|
}
|