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); } }