diff --git a/controllers/InvitationController.php b/controllers/InvitationController.php index 9f76f0a7..95055394 100644 --- a/controllers/InvitationController.php +++ b/controllers/InvitationController.php @@ -3,11 +3,13 @@ use BZIon\Event\Events; use BZIon\Event\TeamInviteEvent; use BZIon\Event\TeamJoinEvent; +use BZIon\Form\Creator\InvitationFormCreator; use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Response; class InvitationController extends CRUDController { - public function acceptAction(Invitation $invitation, Player $me) + public function acceptAction(Player $me, Invitation $invitation) { if (!$me->isTeamless()) { throw new ForbiddenException("You can't join a new team until you leave your current one."); @@ -22,7 +24,7 @@ public function acceptAction(Invitation $invitation, Player $me) } if ($invitation->getTeam()->isDeleted()) { - $invitation->updateExpiration(); + $invitation->setExpired(); throw new ForbiddenException("This invitation is for a team which has been deleted."); } @@ -32,7 +34,8 @@ public function acceptAction(Invitation $invitation, Player $me) return $this->showConfirmationForm(function () use ($invitation, $team, $me) { $team->addMember($me->getId()); - $invitation->updateExpiration(); + $invitation->setStatus(Invitation::STATUS_ACCEPTED); + $invitation->setExpired(); Service::getDispatcher()->dispatch(Events::TEAM_JOIN, new TeamJoinEvent($team, $me)); return new RedirectResponse($team->getUrl()); @@ -40,22 +43,39 @@ public function acceptAction(Invitation $invitation, Player $me) "You are now a member of {$team->getName()}"); } - public function inviteAction(Team $team, Player $player, Player $me) + public function inviteAction(Player $me, Team $team, Player $player) { if (!$me->canEdit($team)) { throw new ForbiddenException("You are not allowed to invite a player to that team!"); - } elseif ($team->isMember($player->getId())) { - throw new ForbiddenException("The specified player is already a member of that team."); - } elseif (Invitation::hasOpenInvitation($player->getId(), $team->getId())) { - throw new ForbiddenException("This player has already been invited to join the team."); } - return $this->showConfirmationForm(function () use ($team, $player, $me) { - $invite = Invitation::sendInvite($player->getId(), $team->getId(), $me->getId()); - Service::getDispatcher()->dispatch(Events::TEAM_INVITE, new TeamInviteEvent($invite)); + $creator = new InvitationFormCreator($team, $me, $this); + $form = $creator->create()->handleRequest(self::getRequest()); - return new RedirectResponse($team->getUrl()); - }, "Are you sure you want to invite {$player->getEscapedUsername()} to {$team->getEscapedName()}?", - "Player {$player->getUsername()} has been invited to {$team->getName()}"); + if ($form->isSubmitted()) { + $this->validate($form); + + if ($form->isValid()) { + $clickedButton = $form->getClickedButton()->getName(); + + if ($clickedButton === 'submit') { + $invitation = $creator->enter($form); + + self::getFlashBag()->add('success', sprintf('"%s" has been invited to "%s."', $invitation->getInvitedPlayer()->getName(), $team->getName())); + + return $this->redirectTo($team); + } + + return (new RedirectResponse($this->getPreviousURL())); + } + } else { + $form->get('invited_player')->setData($player); + } + + return [ + 'form' => $form->createView(), + 'team' => $team, + 'player' => $player, + ]; } } diff --git a/migrations/20180209070716_make_invitations_better.php b/migrations/20180209070716_make_invitations_better.php new file mode 100644 index 00000000..0fbdd17b --- /dev/null +++ b/migrations/20180209070716_make_invitations_better.php @@ -0,0 +1,48 @@ +table('invitations'); + $invitationsTable + ->addColumn('sent', 'datetime', [ + 'after' => 'team', + 'null' => true, + 'default' => null, + 'comment' => 'When the invitation was sent', + ]) + ->addColumn('status', 'integer', [ + 'after' => 'text', + 'null' => false, + 'default' => 0, + 'signed' => true, + 'length' => 1, + 'comment' => '0: pending; 1: accepted; 2: rejected', + ]) + ->addColumn('is_deleted', 'boolean', [ + 'after' => 'status', + 'null' => false, + 'default' => false, + 'comment' => 'Whether or not the invitation has been soft deleted', + ]) + ->update() + ; + + $invitationsTable + ->renameColumn('text', 'message') + ->update() + ; + + $invitationsTable + ->changeColumn('message', 'text', [ + 'null' => true, + 'default' => null, + 'comment' => 'The message sent when inviting a player to a team', + ]) + ->update() + ; + } +} diff --git a/models/Invitation.php b/models/Invitation.php index 33a65eae..5637b3b8 100644 --- a/models/Invitation.php +++ b/models/Invitation.php @@ -28,7 +28,13 @@ class Invitation extends UrlModel * The ID of the team a player was invited to * @var int */ - protected $team; + protected $team_id; + + /** + * The time the invitation was sent + * @var TimeDate + */ + protected $sent; /** * The time the invitation will expire @@ -40,13 +46,27 @@ class Invitation extends UrlModel * The optional message sent to a player to join a team * @var string */ - protected $text; + protected $message; + + /** + * @var int + */ + protected $status; /** - * The name of the database table used for queries + * An array of valid statuses an Invitation can be in. + * + * @var int[] */ + protected static $validStatuses = [self::STATUS_PENDING, self::STATUS_ACCEPTED, self::STATUS_DENIED]; + + const DELETED_COLUMN = 'is_deleted'; + + const STATUS_PENDING = 0; + const STATUS_ACCEPTED = 1; + const STATUS_DENIED = 2; - const TABLE = "invitations"; + const TABLE = 'invitations'; /** * {@inheritdoc} @@ -55,37 +75,19 @@ protected function assignResult($invitation) { $this->invited_player = $invitation['invited_player']; $this->sent_by = $invitation['sent_by']; - $this->team = $invitation['team']; + $this->team_id = $invitation['team']; + $this->sent = TimeDate::fromMysql($invitation['sent']); $this->expiration = TimeDate::fromMysql($invitation['expiration']); - $this->text = $invitation['text']; + $this->status = self::castStatus($invitation['status']); + $this->is_deleted = $invitation['is_deleted']; } /** - * Send an invitation to join a team - * @param int $to The ID of the player who will receive the invitation - * @param int $teamid The team ID to which a player has been invited to - * @param int|null $from The ID of the player who sent it - * @param string $message (Optional) The message that will be displayed to the person receiving the invitation - * @param string|TimeDate|null $expiration The expiration time of the invitation (defaults to 1 week from now) - * @return Invitation The object of the invitation just sent + * {@inheritdoc} */ - public static function sendInvite($to, $teamid, $from = null, $message = "", $expiration = null) + protected function assignLazyResult($invitation) { - if ($expiration === null) { - $expiration = TimeDate::now()->addWeek(); - } else { - $expiration = Timedate::from($expiration); - } - - $invitation = self::create(array( - "invited_player" => $to, - "sent_by" => $from, - "team" => $teamid, - "text" => $message, - "expiration" => $expiration->toMysql(), - )); - - return $invitation; + $this->message = $invitation['text']; } /** @@ -115,7 +117,17 @@ public function getSentBy() */ public function getTeam() { - return Team::get($this->team); + return Team::get($this->team_id); + } + + /** + * Get the timestamp of when the invitation was sent. + * + * @return TimeDate + */ + public function getSendTimestamp() + { + return $this->sent; } /** @@ -128,38 +140,180 @@ public function getExpiration() return $this->expiration->copy(); } + /** + * Get the optional message sent to a player to join a team + * + * @return string + */ + public function getMessage() + { + $this->lazyLoad(); + + return $this->message; + } + + /** + * Get the current status of the Invitation. + * + * @see Invitation::STATUS_PENDING + * @see Invitation::STATUS_ACCEPTED + * @see Invitation::STATUS_DENIED + * + * @since 0.11.0 + * + * @return int + */ + public function getStatus() + { + return $this->status; + } + + /** + * Whether or not an invitation has expired + * + * @return bool + */ + public function isExpired() + { + return $this->expiration->lt(TimeDate::now()); + } + /** * Mark the invitation as having expired * * @return self */ - public function updateExpiration() + public function setExpired() { return $this->updateProperty($this->expiration, 'expiration', TimeDate::now()); } /** - * Get the optional message sent to a player to join a team + * Update the status for this Invitation * - * @return string + * @param int $statusValue + * + * @see Invitation::STATUS_PENDING + * @see Invitation::STATUS_ACCEPTED + * @see Invitation::STATUS_DENIED + * + * @since 0.11.0 + * + * @throws InvalidArgumentException When an invalid status is given as an argument + * + * @return static + */ + public function setStatus($statusValue) + { + if (!in_array($statusValue, self::$validStatuses)) { + throw new InvalidArgumentException('Invalid value was used; see Invitation::$validStatuses for valid values.'); + } + + return $this->updateProperty($this->status, 'status', $statusValue); + } + + /** + * Send an invitation to join a team + * + * @param int $playerID The ID of the player who will receive the invitation + * @param int $teamID The team ID to which a player has been invited to + * @param int|null $from The ID of the player who sent it + * @param string $message (Optional) The message that will be displayed to the person receiving the invitation + * @param string|TimeDate|null $expiration The expiration time of the invitation (defaults to 1 week from now) + * + * @return Invitation The object of the invitation just sent */ - public function getText() + public static function sendInvite($playerID, $teamID, $from = null, $message = '', $expiration = null) { - return $this->text; + if ($expiration === null) { + $expiration = TimeDate::now()->addWeek(); + } else { + $expiration = Timedate::from($expiration); + } + + $invitation = self::create([ + 'invited_player' => $playerID, + 'sent_by' => $from, + 'team' => $teamID, + 'sent' => TimeDate::now()->toMysql(), + 'expiration' => $expiration->toMysql(), + 'message' => $message, + 'status' => self::STATUS_PENDING, + ]); + + return $invitation; + } + + /** + * {@inheritdoc} + */ + public static function getQueryBuilder() + { + return QueryBuilderFlex::createForModel(Invitation::class); + } + + /** + * {@inheritdoc} + */ + public static function getEagerColumnsList() + { + return [ + 'id', + 'invited_player', + 'sent_by', + 'team', + 'sent', + 'expiration', + 'status', + 'is_deleted', + ]; + } + + /** + * {@inheritdoc} + */ + public static function getLazyColumnsList() + { + return [ + 'message', + ]; } /** * Find whether there are unexpired invitations for a player and a team * - * @param int $player - * @param int $team + * @param Player|int $player + * @param Team|int $team + * * @return int */ - public static function hasOpenInvitation($player, $team) + public static function playerHasInvitationToTeam($player, $team) { - return self::fetchCount( - "WHERE invited_player = ? AND team = ? AND expiration > UTC_TIMESTAMP()", - array($player, $team) - ); + return (bool)self::getQueryBuilder() + ->where('invited_player', '=', $player) + ->where('team', '=', $team) + ->where('expiration', '<', 'UTC_TIMESTAMP()') + ->count() + ; + } + + /** + * Cast a value to a valid Invitation status. + * + * @param int $status + * + * @see Invitation::STATUS_PENDING + * @see Invitation::STATUS_ACCEPTED + * @see Invitation::STATUS_DENIED + * + * @return int + */ + protected static function castStatus($status) + { + if (in_array($status, self::$validStatuses)) { + return $status; + } + + return self::STATUS_PENDING; } } diff --git a/src/Form/Creator/InvitationFormCreator.php b/src/Form/Creator/InvitationFormCreator.php new file mode 100644 index 00000000..c1053dd7 --- /dev/null +++ b/src/Form/Creator/InvitationFormCreator.php @@ -0,0 +1,103 @@ + [ + new NotBlankModel(), + ], + 'label' => 'Invitation Recipient', + 'required' => true, + ]); + + $targetTeam = $builder + ->create('target_team', HiddenType::class, [ + 'data' => $this->editing->getId(), + ]) + ->setDataLocked(true) + ; + + $builder + ->add($targetTeam) + ->add('invited_player', $invitedPlayer) + ->add('message', TextareaType::class, [ + 'required' => false, + ]) + ->add('cancel', ButtonType::class, [ + 'label' => 'Cancel', + ]) + ->add('submit', SubmitType::class, [ + 'label' => 'Invite', + 'attr' => [ + 'class' => 'c-button--blue pattern pattern--upward-stripes' + ] + ]) + ->addEventListener(FormEvents::POST_SUBMIT, [$this, 'checkPlayerEligibility']) + ; + + return $builder; + } + + public function checkPlayerEligibility(FormEvent $event) + { + $form = $event->getForm(); + + if ($form->has('invited_player')) { + $formElement = $form->get('invited_player'); + + /** @var \Player|null $proposedInvitee */ + $proposedInvitee = $formElement->getData(); + + if ($proposedInvitee === null) { + $formElement->addError(new FormError('Invited player not found.')); + return; + } + + if ($this->editing->isMember($proposedInvitee->getId())) { + $formElement->addError(new FormError('This player is already a member of that team.')); + } + + if (\Invitation::playerHasInvitationToTeam($proposedInvitee->getId(), $this->editing->getId())) { + $formElement->addError(new FormError('This player already has an invitation to this team.')); + } + } + } + + /** + * {@inheritdoc} + */ + public function enter($form) + { + $invite = \Invitation::sendInvite( + $form->get('invited_player')->getData(), + $form->get('target_team')->getData(), + $this->me->getId(), + $form->get('message')->getData() + ); + \Service::getDispatcher()->dispatch(Events::TEAM_INVITE, new TeamInviteEvent($invite)); + + return $invite; + } +} diff --git a/views/Invitation/invite.html.twig b/views/Invitation/invite.html.twig new file mode 100644 index 00000000..6b26b994 --- /dev/null +++ b/views/Invitation/invite.html.twig @@ -0,0 +1,35 @@ +{% extends 'layout.html.twig' %} + +{% block title %}Inviting {{ player.name }} to {{ team.name }}{% endblock %} + +{% block pageTitle %} +
Are you sure you would like to invite {{ player.name }} to {{ team.name }}?
+ + {{ form_start(form) }} + {{ form_row(form.invited_player, { attr: { class: 'mb3' } }) }} + {{ form_row(form.message, { attr: { class: 'mb3' } }) }} + +