This tutorial is part of the Envato Tuts+ Building Your Startup With PHP series. In this series, I'm guiding you through launching a startup from concept to reality using my Meeting Planner app as a real-life example. Every step along the way, I'll release the Meeting Planner code as open-source examples you can learn from. I'll also address startup-related business issues as they arise.
Changing Your Meeting Plans
As the Meeting Planner alpha testing phase began, the clearest feature gap was the inability to change a meeting after it had been scheduled. It's not an easy problem. Is it okay to just change a meeting without a participant's permission? Or should you ask? Or do either, depending on your role in organizing the meeting? What if you just want to ask if it's okay to meet 15 minutes later—that should be easy, right?
Solving all this required some reflecting on the social aspects of adjusting a meeting.
Over time, I realized that the ability to adjust meetings easily after they've been scheduled could make or break the Meeting Planner brand.
In our last episode on Advanced Scheduling, I implemented Make Changes, which allows an organizer or participant with organizing permissions to essentially reschedule the meeting without asking permission. In today's tutorial, I'll walk you through building the Request Changes infrastructure. It requires that participants request change(s) and then others can accept or decline them, affecting the final meeting calendar details.
While you're reading, I hope you'll try out the new "request a change" feature on the live site and share your thoughts and feedback in the comments below. I do participate in the discussions, but you can also reach me @reifman on Twitter. I'm always open to new feature ideas for Meeting Planner as well as suggestions for future series episodes.
As a reminder, all the code for Meeting Planner is provided open source and written in the Yii2 Framework for PHP. If you'd like to learn more about Yii2, check out my parallel series Programming With Yii2.
Let's get started.
Building Request Changes
A Tall Mountain to Climb
Aside from the meeting view and scheduling features, Request Changes required more time and new code than any other feature on this project.
As I mentioned in the last episode, coding everything with basic security takes a bit longer than rapid prototyping, but designing and building this feature also touched a lot of other platform areas:
- Designing with the social engineering of requesting and making schedule changes.
- Keeping the UX for change requests simple, helping people request and respond to change requests without cluttering the interface.
- Handling requests for 1:1 meetings would be easy, but coding for the upcoming multiple participants feature would require a much more sophisticated infrastructure.
- Handling the responses to requests with multiple participants ahead.
- Emailing notifications of new and withdrawn requests, accepted and declined responses.
- Updating the meeting confirmation and calendar details when accepted requests affect the schedule.
So, while this isn't a perfect picture of the changes just for this feature, here are screenshots of the eventual production server code pull.
Here are the changes to existing code:
And here are the new files:
There was a lot of new code involved with this feature.
The Tables and Their Migrations
Ultimately, I decided on an architecture built around two tables. The first was Requests
:
$this->createTable('{{%request}}', [ 'id' => Schema::TYPE_PK, 'meeting_id' => Schema::TYPE_INTEGER.' NOT NULL DEFAULT 0', 'requestor_id' => Schema::TYPE_BIGINT.' NOT NULL DEFAULT 0', 'completed_by' => Schema::TYPE_BIGINT.' NOT NULL DEFAULT 0', 'time_adjustment' => Schema::TYPE_SMALLINT . ' NOT NULL DEFAULT 0', 'alternate_time' => Schema::TYPE_BIGINT.' NOT NULL DEFAULT 0', 'meeting_time_id' => Schema::TYPE_BIGINT.' NOT NULL DEFAULT 0', 'place_adjustment' => Schema::TYPE_SMALLINT . ' NOT NULL DEFAULT 0', 'meeting_place_id' => Schema::TYPE_BIGINT.' NOT NULL DEFAULT 0', 'note' => Schema::TYPE_TEXT.' NOT NULL DEFAULT ""', 'status' => Schema::TYPE_SMALLINT . ' NOT NULL DEFAULT 0', 'created_at' => Schema::TYPE_INTEGER . ' NOT NULL', 'updated_at' => Schema::TYPE_INTEGER . ' NOT NULL', ], $tableOptions); $this->addForeignKey('fk_request_meeting', '{{%request}}', 'meeting_id', '{{%meeting}}', 'id', 'CASCADE', 'CASCADE'); $this->addForeignKey('fk_request_user', '{{%request}}', 'requestor_id', '{{%user}}', 'id', 'CASCADE', 'CASCADE');
Here are the constants that explain the model further:
const STATUS_OPEN = 0; const STATUS_ACCEPTED = 10; const STATUS_REJECTED = 20; const STATUS_WITHDRAWN = 30; const TIME_ADJUST_NONE = 50; const TIME_ADJUST_ABIT = 60; const TIME_ADJUST_OTHER = 70; const PLACE_ADJUST_NONE = 80; const PLACE_ADJUST_OTHER = 90;
There would be two ways to adjust the time: TIME_ADJUST_ABIT
, i.e. intervals of minutes or hours earlier or later than the chosen time, or TIME_ADJUST_OTHER
, a different meeting time altogether.
And the second table was RequestResponses
:
$this->createTable('{{%request_response}}', [ 'id' => Schema::TYPE_PK, 'request_id' => Schema::TYPE_INTEGER.' NOT NULL DEFAULT 0', 'responder_id' => Schema::TYPE_BIGINT.' NOT NULL DEFAULT 0', 'note' => Schema::TYPE_TEXT.' NOT NULL DEFAULT ""', 'response' => Schema::TYPE_SMALLINT . ' NOT NULL DEFAULT 0', 'created_at' => Schema::TYPE_INTEGER . ' NOT NULL', 'updated_at' => Schema::TYPE_INTEGER . ' NOT NULL', ], $tableOptions);
Basically, who requested the change, who responded to it and what the response was: accept or decline.
The second table is needed for a multiple participant environment.
Requesting a Change
Meeting organizers and participants can access Request Changes via the dropdown Options menu that we built in the last episode:
The Request Change Form
RequestController.php's actionCreate()
loads the form from which the user's request changes:
And here's where the complexity began. What kinds of changes could participants request?
- Do you want to meet earlier or later?
- Do you want to meet at an entirely different time?
- Do you want to meet at a different place?
Note: I've not yet implemented the ability to add new places and times—currently, you can choose alternate date times and places from any that were offered during the planning process.
A Dropdown of Earlier and Later Times
The code to create the dropdown list was intricate. I made it so you could choose times two and a half hours earlier or later, in 15-minute increments near to the original time and 30-minute increments after that:
for ($i=1;$i<12;$i++) { // later times if ($i<4 || $i%2 == 0) { $altTimesList[$chosenTime->start+($i*15*60)]=Meeting::friendlyDateFromTimestamp($chosenTime->start+($i*15*60),$timezone,false); } // earlier times $earlierIndex = ((12-$i)*-15); if ($i%2 == 0 || $i>=9) { $altTimesList[$chosenTime->start+($earlierIndex*60)]=Meeting::friendlyDateFromTimestamp($chosenTime->start+($earlierIndex*60),$timezone,false); } } $altTimesList[$chosenTime->start]='────────────────────'; $altTimesList[-1000]=Yii::t('frontend','Select an alternate time below'); ksort($altTimesList);
I filled $altTimesList
with keys of each possible time with values of the friendly time adjusted for the user's time zone. Then I used ksort()
to sort the dropdown so earlier times showed up before later.
One of Meeting Planner's advisors (I only have one at the moment), suggested showing the currently selected meeting time, which I did below. I also added a separator with the disabled option in a dropdown. It divides earlier times from later times but is not selectable:
Here's the dropdown code, which shows how to disable the separator based on its $currentStart
index key:
<?php echo $form->field($model, 'alternate_time')->label(Yii::t('frontend','Choose a time slightly earlier or later than {currentStartStr}',['currentStartStr'=>$currentStartStr])) ->dropDownList( $altTimesList, ['options' => [$currentStart => ['disabled' => true]]] ); ?>
And, when participants want to pick one of the other times, there's JQuery to change the dropdowns, another complexity to building the forms:
<?php ActiveForm::end(); $this->registerJsFile(MiscHelpers::buildUrl().'/js/request.js',['depends' => [\yii\web\JqueryAsset::className()]]); ?> </div>
Here's /frontend/web/js/request.js:
$("#adjust_how" ).change(function() { if ($("#adjust_how" ).val()==50) { $("#choose_earlier").addClass('hidden'); $("#choose_another").addClass('hidden'); } else if ($("#adjust_how" ).val()==60) { $("#choose_earlier").removeClass('hidden'); $("#choose_another").addClass('hidden'); } else { $("#choose_earlier").addClass('hidden'); $("#choose_another").removeClass('hidden'); } });
Here's what the form looks like with the alternate times hidden:
Different places are just integrated into the place dropdown list (as you can see with the top, featured image).
Handling the Request
Once the request is made, we notify the requestor that other meeting participants will be notified. And, whenever active requests exist for a meeting, there's a link to View Them:
I decided this would be a simple, uncluttered approach for people to access requests.
The List of Meeting Requests
Here's the list of requests for a meeting, most often just one:
Request::buildSubject()
creates the string above based on the content of the request, i.e. time and/or place change:
public static function buildSubject($request_id,$include_requestor = true) { $r = Request::findOne($request_id); $requestor = MiscHelpers::getDisplayName($r->requestor_id); $timezone = MiscHelpers::fetchUserTimezone(Yii::$app->user->getId()); $rtime =''; $place = ''; switch ($r->time_adjustment) { case Request::TIME_ADJUST_NONE: break; case Request::TIME_ADJUST_ABIT: $rtime = Meeting::friendlyDateFromTimestamp($r->alternate_time,$timezone); break; case Request::TIME_ADJUST_OTHER: $t = MeetingTime::findOne($r->meeting_time_id); if (!is_null($t)) { $rtime = Meeting::friendlyDateFromTimestamp($t->start,$timezone);; } break; } if ($r->place_adjustment == Request::PLACE_ADJUST_NONE || $r->place_adjustment == 0 && $r->meeting_place_id ==0 ) { // do nothing } else { // get place name $place = MeetingPlace::findOne($r->meeting_place_id)->place->name; } $result = $requestor.Yii::t('frontend',' asked to meet at '); if ($rtime=='' && $place =='') { $result.=Yii::t('frontend','oops...no changes were requested.'); } else if ($rtime<>'') { $result.=$rtime; if ($place<>'') { $result.=Yii::t('frontend',' and '); } } if ($place<>'') { $result.=$place; } return $result; }
This function is used repeatedly within email notifications as well.
There are also limits in RequestController.php which prevent users from making more than one request per meeting at a time:
public function actionCreate($meeting_id) { // verify is attendee if (!Meeting::isAttendee($meeting_id,Yii::$app->user->getId())) { $this->redirect(['site/authfailure']); } if (Request::countRequestorOpen($meeting_id,Yii::$app->user->getId())>0) { $r = Request::find() ->where(['meeting_id'=>$meeting_id]) ->andWhere(['requestor_id'=>Yii::$app->user->getId()]) ->andWhere(['status'=>Request::STATUS_OPEN]) ->one(); Yii::$app->getSession()->setFlash('info', Yii::t('frontend','You already have an existing request below.')); return $this->redirect(['view','id'=>$r->id]); }
Here's the view request page showing the limiting:
If it's your own request, you can Withdraw Your Request.
As you can see, there was a lot of diverse UX functionality to build for this. And I haven't shown you when people other than the requestor respond.
Request and Response Notification Emails
In the process of building these features, I decided to create generic_html
and generic_text
email templates as well as a reusable Request::notify()
function to ease the delivery of different kinds of announcements around Meeting Planner.
Here's the Request::create()
method for preparing an email:
public function create() { $user_id = $this->requestor_id; $meeting_id = $this->meeting_id; $subject = Request::buildSubject($this->id); $content=[ 'subject' => Yii::t('frontend','Change Requested to Your Meeting'), 'heading' => Yii::t('frontend','Requested Change to Your Meeting'), 'p1' => $subject, 'p2' => $this->note, 'plain_text' => $subject.' '.$this->note.'...'.Yii::t('frontend','Respond to the request by visiting this link: '), ]; $button= [ 'text' => Yii::t('frontend','Respond to Request'), 'command' => Meeting::COMMAND_VIEW_REQUEST, 'obj_id' => $this->id, ]; $this->notify($user_id,$meeting_id, $content,$button); // add to log MeetingLog::add($meeting_id,MeetingLog::ACTION_REQUEST_SENT,$user_id,0); }
The $content
array is populated for the email subject, message heading and paragraphs, while the $button
array is used for any command button such as Respond to Request or View Meeting.
Here's the notify()
method, similar to the earlier send()
and finalize()
actions which send email:
public static function notify($user_id,$meeting_id,$content,$button = false) { // sends a generic message based on arguments $mtg = Meeting::findOne($meeting_id); // build an attendees array for all participants without contact information $cnt =0; $attendees = array(); foreach ($mtg->participants as $p) { $auth_key=\common\models\User::find()->where(['id'=>$p->participant_id])->one()->auth_key; $attendees[$cnt]=['user_id'=>$p->participant_id,'auth_key'=>$auth_key, 'email'=>$p->participant->email, 'username'=>$p->participant->username]; $cnt+=1; } // add organizer $auth_key=\common\models\User::find()->where(['id'=>$mtg->owner_id])->one()->auth_key; $attendees[$cnt]=['user_id'=>$mtg->owner_id,'auth_key'=>$auth_key, 'email'=>$mtg->owner->email, 'username'=>$mtg->owner->username]; // use this code to send foreach ($attendees as $cnt=>$a) { // check if email is okay and okay from this sender_id if ($user_id != $a['user_id'] && User::checkEmailDelivery($a['user_id'],$user_id)) { Yii::$app->timeZone = $timezone = MiscHelpers::fetchUserTimezone($a['user_id']); // Build the absolute links to the meeting and commands $links=[ 'home'=>MiscHelpers::buildCommand($mtg->id,Meeting::COMMAND_HOME,0,$a['user_id'],$a['auth_key']), 'view'=>MiscHelpers::buildCommand($mtg->id,Meeting::COMMAND_VIEW,0,$a['user_id'],$a['auth_key']), 'footer_email'=>MiscHelpers::buildCommand($mtg->id,Meeting::COMMAND_FOOTER_EMAIL,0,$a['user_id'],$a['auth_key']), 'footer_block'=>MiscHelpers::buildCommand($mtg->id,Meeting::COMMAND_FOOTER_BLOCK,$user_id,$a['user_id'],$a['auth_key']), 'footer_block_all'=>MiscHelpers::buildCommand($mtg->id,Meeting::COMMAND_FOOTER_BLOCK_ALL,0,$a['user_id'],$a['auth_key']), ]; if ($button!==false) { $links['button_url']=MiscHelpers::buildCommand($mtg->id,$button['command'],$button['obj_id'],$a['user_id'],$a['auth_key']); $content['button_text']=$button['text']; } // send the message $message = Yii::$app->mailer->compose([ 'html' => 'generic-html', 'text' => 'generic-text' ], [ 'meeting_id' => $mtg->id, 'sender_id'=> $user_id, 'user_id' => $a['user_id'], 'auth_key' => $a['auth_key'], 'links' => $links, 'content'=>$content, 'meetingSettings' => $mtg->meetingSettings, ]); // to do - add full name $message->setFrom(array('[email protected]'=>$mtg->owner->email)); $message->setReplyTo('mp_'.$mtg->id.'@meetingplanner.io'); $message->setTo($a['email']) ->setSubject($content['subject']) ->send(); } } }
Essentially, the generic_html.php layout is based on the simple textual update template I talked about in our email templates tutorials. It provides a well-formatted way to update participants via email with a few paragraphs.
Here's the generic_html.php view file integrating the $content
and $button
data. It checks for a second and third paragraph, e.g. $p2
, $p3
and $button
data:
<tr> <td style="color:#777; font-family:Helvetica, Arial, sans-serif; font-size:14px; line-height:21px; text-align:center; border-collapse:collapse; padding:10px 60px 0; width:100%" align="center" width="100%"> <p>Hi <?php echo Html::encode(MiscHelpers::getDisplayName($user_id)); ?>,</p> <p><?= Html::encode($content['p1']) ?></p> <?php if ($content['p2']<>'') { ?> <p><?= Html::encode($content['p2']); ?></p> <?php } ?> <?php if (isset($content['p3']) && $content['p3']<>'') { ?> <p><?= Html::encode($content['p3']); ?></p> <?php } ?> </td> </tr> <?php if ($links['button_url']!='') { ?> <tr> <td style="color:#777; font-family:Helvetica, Arial, sans-serif; font-size:14px; line-height:21px; text-align:center; border-collapse:collapse; padding:30px 0 30px 0" align="center"> <div> <!--[if mso]> <v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="http://" style="height:45px;v-text-anchor:middle;width:155px;" arcsize="15%" strokecolor="#ffffff" fillcolor="#ff6f6f"> <w:anchorlock/> <center style="color:#ffffff;font-family:Helvetica, Arial, sans-serif;font-size:14px;font-weight:regular;">My Account</center> </v:roundrect> <![endif]--><a href="<?php echo $links['button_url'] ?>" style='color:#fff; text-decoration:none; -webkit-text-size-adjust:none; background-color:#ff6f6f; border-radius:5px; display:inline-block; font-family:"Cabin", Helvetica, Arial, sans-serif; font-size:14px; font-weight:regular; line-height:45px; mso-hide:all; text-align:center; width:155px' bgcolor="#ff6f6f" align="center" width="155"><?= Html::encode($content['button_text']); ?></a> </div> </td> </tr> <?php } ?>
Here's an example of a notification that Rob Smith asked me to change our meeting time and place (generated from the code above):
Responding to Requests
When I click Respond to Request, I'm taken to the RequestResponse
Controller's actionCreate()
method:
Throughout the request UX, I incorporated the ability for people to write notes providing context for the requests and responses.
One challenge of this form was determining how to direct responses to different controller methods based on which submit button was clicked. In other words, distinguishing between different submit POST button clicks.
public function actionCreate($id) { $request = Request::findOne($id); if (!Meeting::isAttendee($request->meeting_id,Yii::$app->user->getId())) { $this->redirect(['site/authfailure']); } // has this user already responded $check = RequestResponse::find() ->where(['request_id'=>$id]) ->andWhere(['responder_id'=>Yii::$app->user->getId()]) ->count(); if ($check>0) { Yii::$app->getSession()->setFlash('error', Yii::t('frontend','Sorry, you already responded to this request.')); return $this->redirect(['meeting/view', 'id' => $request->meeting_id]); } if ($request->requestor_id == Yii::$app->user->getId()) { Yii::$app->getSession()->setFlash('error', Yii::t('frontend','Sorry, can not respond to your own request.')); return $this->redirect(['meeting/view', 'id' => $request->meeting_id]); } $subject = Request::buildSubject($id); $model = new RequestResponse(); $model->request_id = $id; $model->responder_id = Yii::$app->user->getId(); if ($model->load(Yii::$app->request->post()) ) { $posted = Yii::$app->request->post(); if (isset($posted['accept'])) { // accept $model->response = RequestResponse::RESPONSE_ACCEPT; $model->save(); $request->accept($model); Yii::$app->getSession()->setFlash('success', Yii::t('frontend','Request accepted. We will update the meeting details and inform other participants.')); } else { // reject $model->response = RequestResponse::RESPONSE_REJECT; $model->save(); $request->reject($model); Yii::$app->getSession()->setFlash('success', Yii::t('frontend','Your decline has been recorded. We will let other participants know.')); } return $this->redirect(['/meeting/view', 'id' => $request->meeting_id]); } else { return $this->render('create', [ 'model' => $model, 'subject' => $subject, 'meeting_id' => $request->meeting_id, ]); } }
Here's /frontend/views/request-response/_form.php:
<div class="request-response-form"> <?php $form = ActiveForm::begin(); ?> <?= BaseHtml::activeHiddenInput($model, 'responder_id'); ?> <?= BaseHtml::activeHiddenInput($model, 'request_id'); ?> <?= $form->field($model, 'note')->label(Yii::t('frontend','Include a note'))->textarea(['rows' => 6])->hint(Yii::t('frontend','optional')) ?> <div class="form-group"> <?= Html::submitButton(Yii::t('frontend', 'Accept Request'), ['class' => 'btn btn-success','name'=>'accept',]) ?> <?= Html::submitButton(Yii::t('frontend', 'Decline Request'), ['class' => 'btn btn-danger','name'=>'reject', 'data' => [ 'confirm' => Yii::t('frontend', 'Are you sure you want to decline this request?'), 'method' => 'post', ],]) ?>
Essentially, I just added name
values of 'accept'
or 'reject'
to each button. Then, this is delivered as a posted value as shown:
if ($model->load(Yii::$app->request->post()) ) { $posted = Yii::$app->request->post(); if (isset($posted['accept'])) { // accept $model->response = RequestResponse::RESPONSE_ACCEPT; $model->save(); $request->accept($model); Yii::$app->getSession()->setFlash('success', Yii::t('frontend','Request accepted. We will update the meeting details and inform other participants.')); } else { // reject $model->response = RequestResponse::RESPONSE_REJECT; $model->save(); $request->reject($model); Yii::$app->getSession()->setFlash('success', Yii::t('frontend','Your decline has been recorded. We will let other participants know.')); } return $this->redirect(['/meeting/view', 'id' => $request->meeting_id]); }
When the responder accepts or declines the request, they are shown a flash message and sent an email. Also, the meeting no longer has any active requests to show:
Here's the Requested Change Accepted notification email:
A lot happens in Request::accept()
below:
public function accept($request_response) { // to do - this will need to change when there are multiple participants $this->status = Request::STATUS_ACCEPTED; $this->update(); $m = Meeting::findOne($this->meeting_id); // is there a new time switch ($this->time_adjustment) { case Request::TIME_ADJUST_ABIT: // create a new meeting time with alternate_time $this->meeting_time_id = MeetingTime::addFromRequest($this->id); $this->update(); // mark as selected MeetingTime::setChoice($this->meeting_id,$this->meeting_time_id,$request_response->responder_id); break; case Request::TIME_ADJUST_OTHER: // mark as selected MeetingTime::setChoice($this->meeting_id,$this->meeting_time_id,$request_response->responder_id); break; } // is there a different place if ($this->place_adjustment == Request::PLACE_ADJUST_OTHER || $this->meeting_place_id !=0 ) { MeetingPlace::setChoice($this->meeting_id,$this->meeting_place_id,$request_response->responder_id); } if ($m->isOwner($request_response->responder_id)) { // they are an organizer $this->completed_by =$request_response->responder_id; $this->update(); MeetingLog::add($this->meeting_id,MeetingLog::ACTION_REQUEST_ORGANIZER_ACCEPT,$request_response->responder_id,$this->id); } else { // they are a participant MeetingLog::add($this->meeting_id,MeetingLog::ACTION_REQUEST_ACCEPT,$request_response->responder_id,$this->id); } $user_id = $request_response->responder_id; $subject = Request::buildSubject($this->id, true); $p1 = MiscHelpers::getDisplayName($user_id).Yii::t('frontend',' accepted the request: ').$subject; $p2 = $request_response->note; $p3 = Yii::t('frontend','You will receive an updated meeting confirmation reflecting these change(s). It will also include an updated attachment for your Calendar.'); $content=[ 'subject' => Yii::t('frontend','Accepted Requested Change to Meeting'), 'heading' => Yii::t('frontend','Requested Change Accepted'), 'p1' => $p1, 'p2' => $p2, 'p3' => $p3, 'plain_text' => $p1.' '.$p2.' '.$p3.'...'.Yii::t('frontend','View the meeting here: '), ]; $button= [ 'text' => Yii::t('frontend','View the Meeting'), 'command' => Meeting::COMMAND_VIEW, 'obj_id' => 0, ]; $this->notify($user_id,$this->meeting_id, $content,$button); // Make changes to the Meeting $m->increaseSequence(); // resend the finalization - which also needs to be done for resend invitation $m->finalize($m->owner_id); }
Prior to sending the email, the meeting schedule is updated to reflect any new date/time and/or new place. After sending the email, the meeting is finalized. This delivers a new meeting update with the updated calendar file to all participants:
What's Next?
I hope you've enjoyed this tutorial. Building this feature took longer than I hoped but turned out quite well. I think it adds a dimension to scheduling with Meeting Planner unmatched by other services.
If you haven't yet, go schedule your first meeting with Meeting Planner. I've continued to make incredible progress towards the beta release, despite distractions (coding is hard):
I'm also planning to write a tutorial about crowdfunding, so please consider following our WeFunder Meeting Planner page.
Please share your comments below. I'm always open to new feature ideas and topic suggestions for future tutorials. You can also reach out to me @reifman.
Stay tuned for all of this and more upcoming tutorials by checking out the Building Your Startup With PHP series. And definitely check out our Programming With Yii2 Series (Envato Tuts+).
Comments