connect(),"SELECT i.invitation_serial,c.club_name,i.membership_role,i.expires_at FROM club_invitations i JOIN clubs c ON c.club_serial=i.club_serial JOIN users u ON LOWER(u.user_email)=LOWER(i.invite_email) WHERE u.user_serial=? AND i.invitation_status='pending' AND i.expires_at>UTC_TIMESTAMP() AND c.club_status='active' ORDER BY i.invitation_serial",[$_SESSION[self::USER_SERIAL]??0],$page,'pending'); } public function forClub(int $club,?int $page=null): array { (new TenantAccess())->requireManager($club);$this->club($club); return BoundedList::rows($this->connect(),'SELECT invitation_serial,invite_email,membership_role,invitation_status,expires_at FROM club_invitations WHERE club_serial=? ORDER BY invitation_serial DESC',[$club],$page,'invitations'); } public function invite(int $club,string $email,string $role='member'): array { $email=trim($email);$access=new TenantAccess();$access->requireManager($club); if(!filter_var($email,FILTER_VALIDATE_EMAIL)||strlen($email)>100||!in_array($role,['member','administrator'],true))throw new AppException('Enter a valid invitation email and club role.'); if($role==='administrator')$access->requireSuper(); if(!(new AuthService())->allowAttempt('club-invite',($_SESSION[self::USER_SERIAL]??0).':'.$club,20,3600))throw new AppException('Too many invitations. Try again later.',429); (new ClubResources())->consume($club,'email'); $db=$this->connect(); $selected=DatabaseTransaction::run($db,function(PDO $db)use($club,$email,$role,$access){ $selected=$this->club($club,$db,true); if($role==='administrator')$access->requireSuper($db); $stmt=$db->prepare('SELECT * FROM club_invitations WHERE club_serial=? AND invite_email=? FOR UPDATE');$stmt->execute([$club,$email]);$existing=$stmt->fetch(); if($existing && strtotime($existing['sent_at'].' UTC')>time()-60)throw new AppException('Wait a minute before sending this invitation again.',429); if($existing && $existing['membership_role']==='administrator')$access->requireSuper($db); $stmt=$db->prepare("INSERT INTO club_invitations(club_serial,invite_email,membership_role,invited_by,expires_at,sent_at) VALUES(?,?,?,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 7 DAY),UTC_TIMESTAMP()) ON DUPLICATE KEY UPDATE membership_role=VALUES(membership_role),invited_by=VALUES(invited_by),invitation_status='pending',expires_at=VALUES(expires_at),sent_at=VALUES(sent_at),accepted_at=NULL"); $stmt->execute([$club,$email,$role,$_SESSION[self::USER_SERIAL]]); Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'invited club member '.json_encode(['club'=>$club,'email'=>$email,'role'=>$role]));return $selected; }); $sent=false;try{$sent=$this->deliver($email,$selected['club_name']);}catch(Throwable $e){error_log('Club invitation delivery failed: '.$e->getMessage());} return ['ok'=>true,'message'=>'Invitation saved. The recipient must sign in with this email, verify it, and accept the invitation.','warning'=>$sent?'':'The invitation email could not be sent. The recipient can still sign in and open Invitations; you can retry sending after a minute.']; } protected function deliver(string $email,string $club): bool { $url=rtrim((string)$this->getSettings()->ApplicationUrl,'/'); if(!filter_var($url,FILTER_VALIDATE_URL)||parse_url($url,PHP_URL_SCHEME)!=='https'||parse_url($url,PHP_URL_QUERY)!==null||parse_url($url,PHP_URL_FRAGMENT)!==null||parse_url($url,PHP_URL_USER)!==null||parse_url($url,PHP_URL_PASS)!==null)throw new RuntimeException('Configure a trusted HTTPS ApplicationUrl.'); $h=static fn($v)=>htmlspecialchars($v,ENT_QUOTES,'UTF-8'); $body='

You have been invited to '.$h($club).'.

Sign in and open Invitations. If you do not have an account, use invited member signup with this email and verify it. Accepting the invitation grants access to this club.

This invitation expires in seven days.

'; return (new Email())->sendEmail($email,self::APPLICATION_NAME.' - Club invitation',$body); } public function accept(int $id): void { $db=$this->connect(); DatabaseTransaction::run($db,function(PDO $db)use($id){ // Match membership/account edits: lock the recipient before the club. $stmt=$db->prepare('SELECT club_serial FROM club_invitations WHERE invitation_serial=?');$stmt->execute([$id]);$club=(int)$stmt->fetchColumn(); $stmt=$db->prepare('SELECT user_serial,user_email,user_email_verified_at,user_status FROM users WHERE user_serial=? FOR UPDATE');$stmt->execute([$_SESSION[self::USER_SERIAL]??0]);$user=$stmt->fetch(); $stmt=$db->prepare('SELECT club_status FROM clubs WHERE club_serial=? FOR UPDATE');$stmt->execute([$club]);if($stmt->fetchColumn()!=='active')throw new AppException('Invitation is unavailable.',409); $stmt=$db->prepare("SELECT * FROM club_invitations WHERE invitation_serial=? AND invitation_status='pending' AND expires_at>UTC_TIMESTAMP() FOR UPDATE");$stmt->execute([$id]);$invite=$stmt->fetch(); if(!$invite||!$user||strcasecmp($invite['invite_email'],$user['user_email'])!==0)throw new AppException('Invitation is unavailable.',404); if(!$user['user_email_verified_at']||$user['user_status']!=='active')throw new AppException('Verify your account email before accepting an invitation.',403); $access=new TenantAccess(); if(!$access->canManage($club,$db,(int)$invite['invited_by']) || ($invite['membership_role']==='administrator'&&!$access->isSuper($db,(int)$invite['invited_by'])))throw new AppException('Ask a current club administrator to send a new invitation.',409); $stmt=$db->prepare("INSERT INTO club_memberships(club_serial,user_serial,membership_status,joined_on,membership_role) VALUES(?,?,'active',?,?) ON DUPLICATE KEY UPDATE membership_role=IF(membership_status='active' AND membership_role='administrator','administrator',VALUES(membership_role)),membership_status='active'");$stmt->execute([$club,$user['user_serial'],date('Y-m-d'),$invite['membership_role']]); $stmt=$db->prepare("UPDATE club_invitations SET invitation_status='accepted',accepted_at=UTC_TIMESTAMP() WHERE invitation_serial=?");$stmt->execute([$id]); Audit::required($db,(int)$user['user_serial'],'accepted club invitation '.json_encode(['club'=>$club,'invitation'=>$id,'role'=>$invite['membership_role']])); }); } public function revoke(int $club,int $id): void { $db=$this->connect();DatabaseTransaction::run($db,function(PDO $db)use($club,$id){ $this->club($club,$db,true); $stmt=$db->prepare('SELECT membership_role FROM club_invitations WHERE invitation_serial=? AND club_serial=? FOR UPDATE');$stmt->execute([$id,$club]);$role=$stmt->fetchColumn();if(!$role)throw new AppException('Invitation not found.',404); if($role==='administrator')(new TenantAccess())->requireSuper($db); $stmt=$db->prepare("UPDATE club_invitations SET invitation_status='revoked' WHERE invitation_serial=? AND club_serial=? AND invitation_status='pending'");$stmt->execute([$id,$club]); Audit::required($db,(int)$_SESSION[self::USER_SERIAL],'revoked club invitation '.json_encode(['club'=>$club,'invitation'=>$id])); }); } }