generated from jric11/baseProject
65 lines
7.6 KiB
PHP
65 lines
7.6 KiB
PHP
|
|
<?php
|
||
|
|
/** Invitations grant only club membership, never global account privileges. */
|
||
|
|
class ClubInvitations extends RosterService {
|
||
|
|
public function pending(?int $page=null): array {
|
||
|
|
return BoundedList::rows($this->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='<p>You have been invited to '.$h($club).'.</p><p><a href="'.$h($url.'/main.php?Xaction=Roster.invitations').'">Sign in and open Invitations</a>. If you do not have an account, use <a href="'.$h($url.'/main.php?Xaction=signup&membership=invited').'">invited member signup</a> with this email and verify it. Accepting the invitation grants access to this club.</p><p>This invitation expires in seven days.</p>';
|
||
|
|
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]));
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|