$limit)throw new AppException($label.' must be '.$limit.' characters or fewer.'); return $value; } public function normalizeSearch(string $search): string { $search=trim($search);return $search===''?'':$this->text($search,100,'Ticket search'); } public function page(int $club,int $page=1,string $search=''): array { $this->club($club);if($page<1)throw new AppException('Choose a valid ticket page.'); $search=$this->normalizeSearch($search);$manage=$this->manages($club);$args=$manage?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0]; $scope=' WHERE club_serial=?'.($manage?'':' AND user_serial=?'); if($search!==''){$scope.=" AND subject LIKE ? ESCAPE '!'";$args[]='%'.str_replace(['!','%','_'],['!!','!%','!_'],$search).'%';} $db=$this->connect();$stmt=$db->prepare('SELECT COUNT(*) FROM club_tickets'.$scope);$stmt->execute($args);$count=(int)$stmt->fetchColumn(); $paging=Pagination::calculate($count,25,$page);$pages=max(1,(int)ceil($count/$paging['size'])); $stmt=$db->prepare('SELECT ticket_serial,subject,ticket_status,user_serial,created_at FROM club_tickets'.$scope.' ORDER BY ticket_serial DESC LIMIT '.$paging['size'].' OFFSET '.$paging['offset']);$stmt->execute($args); return $paging+['rows'=>$stmt->fetchAll(),'count'=>$count,'pages'=>$pages,'search'=>$search]; } public function listing(int $club,int $page=1,string $search=''): array {return $this->page($club,$page,$search)['rows'];} public function ticket(int $club,int $id,?PDO $db=null): array { $this->club($club,$db);$stmt=($db??$this->connect())->prepare('SELECT * FROM club_tickets WHERE club_serial=? AND ticket_serial=?');$stmt->execute([$club,$id]);$row=$stmt->fetch(); if(!$row||(!$this->manages($club,$db)&&(int)$row['user_serial']!==(int)($_SESSION[self::USER_SERIAL]??0)))throw new AppException('Ticket not found.',404);return $row; } public function messages(int $club,int $id,?int $page=null): array { $this->ticket($club,$id);return BoundedList::rows($this->connect(),'SELECT m.message_text,m.created_at,u.user_first_name,u.user_last_name FROM club_ticket_messages m JOIN users u ON u.user_serial=m.author_serial WHERE ticket_serial=? ORDER BY message_serial',[$id],$page,'messages'); } public function createTicket(int $club,string $subject,string $message): int { $subject=$this->text($subject,self::SUBJECT_LIMIT,'Subject');$message=$this->text($message,self::MESSAGE_LIMIT,'Message'); if(!(new AuthService())->allowAttempt('club-ticket',(string)($_SESSION[self::USER_SERIAL]??0),20,3600))throw new AppException('Too many tickets. Try again later.',429); (new ClubResources())->consume($club,'ticket'); $db=$this->connect();return DatabaseTransaction::run($db,function(PDO $db)use($club,$subject,$message){ $selected=$this->club($club,$db);$stmt=$db->prepare('SELECT club_status FROM clubs WHERE club_serial=? FOR UPDATE');$stmt->execute([$club]);if($stmt->fetchColumn()!=='active')throw new AppException('This club is archived.',409); $this->club($club,$db);$stmt=$db->prepare('INSERT INTO club_tickets(club_serial,user_serial,subject) VALUES(?,?,?)');$stmt->execute([$club,$_SESSION[self::USER_SERIAL],$subject]);$id=(int)$db->lastInsertId(); $stmt=$db->prepare('INSERT INTO club_ticket_messages(ticket_serial,author_serial,message_text) VALUES(?,?,?)');$stmt->execute([$id,$_SESSION[self::USER_SERIAL],$message]);Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'opened club ticket '.json_encode(['club'=>$club,'ticket'=>$id]));return $id; }); } public function reply(int $club,int $id,string $message,string $status=''): void { $message=$this->text($message,self::MESSAGE_LIMIT,'Message');if(!in_array($status,['','open','closed'],true))throw new AppException('Invalid ticket status.'); if(!(new AuthService())->allowAttempt('ticket-reply',(string)($_SESSION[self::USER_SERIAL]??0),60,3600))throw new AppException('Too many replies. Try again later.',429); $this->ticket($club,$id);(new ClubResources())->consume($club,'reply'); $db=$this->connect();DatabaseTransaction::run($db,function(PDO $db)use($club,$id,$message,$status){ $stmt=$db->prepare('SELECT club_status FROM clubs WHERE club_serial=? FOR UPDATE');$stmt->execute([$club]);if($stmt->fetchColumn()!=='active')throw new AppException('This club is archived.',409); $row=$this->ticket($club,$id,$db);if($status!=='')(new TenantAccess())->requireManager($club,$db); if($row['ticket_status']==='closed'&&$status!=='open')throw new AppException('A club administrator must reopen the ticket before replying.',409); $stmt=$db->prepare('INSERT INTO club_ticket_messages(ticket_serial,author_serial,message_text) VALUES(?,?,?)');$stmt->execute([$id,$_SESSION[self::USER_SERIAL],$message]); $stmt=$db->prepare('UPDATE club_tickets SET ticket_status=?,updated_at=CURRENT_TIMESTAMP WHERE ticket_serial=?');$stmt->execute([$status?:$row['ticket_status'],$id]);Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'replied to club ticket '.json_encode(['club'=>$club,'ticket'=>$id,'status'=>$status?:$row['ticket_status']])); }); } }