*/ public const VALID_STATUSES = [ self::STATUS_PENDING, self::STATUS_ACCEPTED, self::STATUS_REVOKED ]; /** * Days a pending invite remains usable after it is created. Limits the window * in which a leaked or forwarded invitation link can be redeemed. */ public const EXPIRY_DAYS = 14; /** * Hash a raw invitation token for storage and lookup. Only the hash is * persisted, so a database leak (backup, SQL injection elsewhere) cannot be * used to redeem pending invites; the raw token exists only in the emailed * link and is shown to the admin once, at creation. */ public static function hashToken( string $rawToken ): string { return hash( 'sha256', $rawToken ); } public function __construct( public readonly string $email, public readonly string $token, public readonly string $role = RoleManager::STUDENT, public readonly string $status = self::STATUS_PENDING, public readonly ?int $invitedBy = null, public readonly ?int $acceptedUserId = null, public readonly ?string $acceptedAt = null, public readonly ?string $createdAt = null, public readonly ?int $id = null, ) {} public static function fromRow( object $row ): self { return new self( email: $row->email, token: $row->token, role: $row->role, status: $row->status, invitedBy: null !== $row->invited_by ? (int) $row->invited_by : null, acceptedUserId: null !== $row->accepted_user_id ? (int) $row->accepted_user_id : null, acceptedAt: $row->accepted_at, createdAt: $row->created_at ?? null, id: (int) $row->id, ); } public function isPending(): bool { return self::STATUS_PENDING === $this->status; } /** * Whether the invite was created more than {@see EXPIRY_DAYS} ago, measured * against the supplied current `Y-m-d H:i:s` timestamp. An invite with no * known creation time is treated as not expired. */ public function isExpired( string $now ): bool { if ( null === $this->createdAt ) { return false; } $created = strtotime( $this->createdAt ); $current = strtotime( $now ); if ( false === $created || false === $current ) { return false; } return ( $current - $created ) > self::EXPIRY_DAYS * 86400; } /** * Whether this invite can still be redeemed: pending and not expired. */ public function isAcceptable( string $now ): bool { return $this->isPending() && ! $this->isExpired( $now ); } /** * Returns a plain array representation of the invite. * * @return array */ public function toArray(): array { return [ 'id' => $this->id, 'email' => $this->email, 'token' => $this->token, 'role' => $this->role, 'status' => $this->status, 'invited_by' => $this->invitedBy, 'accepted_user_id' => $this->acceptedUserId, 'accepted_at' => $this->acceptedAt, ]; } }