The Need
During the Praxis bootcamp, participants are expected to make every single day a non-zero day. Most participants ask us to hold them accountable, so they email one of our staff each day and that person emails them the next day if they miss a check-in. This takes an enormous number of emails, staff overhead to keep track of, and is difficult to search. Isaac asked me, “What can we do to automate this?”
It needs this functionality:
- The ability to submit what you’ve worked on that day.
- The ability to see what you’ve submitted.
- The ability for staff to see what you’ve submitted.
- The ability to send an email to people who didn’t submit a check-in the previous day.
- The ability for logging who missed a check-in.
The Restrictions
There are existing systems that do this sort of thing, so why make our own? During the bootcamp, our customers use what we call The Portal, which is a curriculum platform I built on top of WordPress with the help of Restrict Content Pro and wpcomplete. I didn’t want to add another destination into the mix. I wanted a reason for customers to go to the Portal every day and keep their work moving forward. Plus, they already have logins there. No need to add yet another login for them to keep track of.
The Solution Sketch
- USP Pro for front-end post submission
- Custom page template to display the content box but hide the tedious stuff: User ID, today’s date, user email
- New WordPress user meta field for keeping track of accountability opt-ins
- Custom script to check if opt-ins have posted within the last 28 hours and fire off a webhook to Zapier to send the email and log missed check-ins to a spreadsheet
- Zapier time trigger with a GET hook to run the script at a specific time to kick off the accountability check
- Custom template to display all opted-in participants and their check-ins to make it easy for staff to see everything in one place
- Hide the check-ins from search results
The Solution Details
Front-end post submission and showing posts
USP Pro handles all sorts of complexity when it comes to submitting posts from the front-end of a WordPress site: The form, the custom post type, assigning the current logged-in user to be the author, and displaying success and error messages. They also have hooks and filters to make using the posts in templates easier.
I used a custom template to hide some of the things I’d need to make the whole process work: User ID to assign as the post author, name and today’s date for the title of the post.
After the form, we also want to show all the posts someone has already submitted. I do that with a WP_Query
_
Note: I added a Delete button here in case some makes an accidental post. If you want someone to be able to delete their check-in post, they need to have the role Author.
Here is the code I used for the template and comments about what it does:
// Check if user is logged in. Display the check-in form if so, login form if not.
if ( is_user_logged_in() ) {
//Get the logged in user, today's date, and the PHP session ID
$current_user = wp_get_current_user();
$today = date("F j, Y");
$ses_id = session_id();
?>
<div class="usp-pro-form">
<!-- USP Pro @ https://plugin-planet.com/usp-pro/ -->
<div id="usp-pro" class="usp-pro usp-form-4102">
<form id="usp-form-4102" class="usp-form" method="post" enctype="multipart/form-data" action="" data-parsley-validate="" data-persist="garlic">
<fieldset class="usp-fieldset usp-fieldset-default">
<label for="usp-content" class="usp-label usp-label-content"><h2>What did you do to make today a non-zero day?</h2></label>
<textarea name="usp-content" id="usp-content" rows="5" cols="30" maxlength="999999" data-required="true" required="required" placeholder="Today I..." class="usp-input usp-textarea usp-input-content"></textarea>
<input type="hidden" name="usp-content-required" value="1">
</fieldset>
<div class="usp-hidden">
// Get first and last name, today's date, set that as the title.
<?php echo '<input name="usp-title" value="' . $current_user->user_firstname . ' ' . $current_user->user_lastname . ' ' . $today . '" type="hidden" />';
// Get user ID for author
echo '<input type="hidden" name="usp-logged-id" value="' . $current_user->ID . '">';
// Get PHP session ID. Plugin thing.
echo '<input type="hidden" name="PHPSESSID" value="' . $ses_id . '">';
?>
<input type="text" name="usp-verify" id="verify" value="" style="display:none;" class="exclude">
<input type="hidden" name="usp-form-id" value="4102">
</div>
<!-- Using the shortcode for the submit button because it includes a bunch of error checking, success message, and other stuff. -->
<?php echo do_shortcode('[usp_form id="submit"]'); ?>
</form>
</div>
</div>
<div class="submitted-checkins">
<h2>Your submitted check-ins:</h2>
<?php
// Show posts from this user ID with the type usp_post
$post_query = new WP_Query( array(
'post_type' => 'usp_post',
'author' => $current_user->ID,
'orderby' => 'date',
'order' => 'DESC',
) );
if ( $post_query->have_posts() ) :
while( $post_query->have_posts() ) : $post_query->the_post(); ?>
<article <?php post_class(); ?>>
<h3><?php the_time('F j, Y \a\t g:i a') ?></h3>
<div><?php the_content(); ?><a href="<?php echo get_delete_post_link(); ?>" style="font-size: .8em;">Delete this check-in</a></div>
</article>
<?php
endwhile;
else:
echo "<p>You have no check-in posts. Submit one above!</p>";
endif;
?>
</div>
<?php
} else {
?><div style="padding:50px;"><?php
echo do_shortcode('[login_form]');
?></div><?php
}
?>
The page (with a little bit of CSS added, which I’ll leave as an exercise for the reader) looks like this:
Adding custom user meta field for accountability opt-in
This goes in functions.php
:
add_action( 'show_user_profile', 'accountability_opt_in' );
add_action( 'edit_user_profile', 'accountability_opt_in' );
function accountability_opt_in( $user ) { ?>
<h3>Daily Accountability</h3>
<table class="form-table">
<tr>
<th><label for="checkin">Opt in to daily check-in accountability?</label></th>
<td>
<input type="checkbox" name="checkin" id="checkin" <?php if (get_the_author_meta( 'checkin', $user->ID) == 'True' ) { ?>checked="checked"<?php }?> value="True" /><br />
<span class="description">Yes, opt me in.</span>
</td>
</tr>
</table>
<?php }
add_action( 'personal_options_update', 'save_accountability_opt_in' );
add_action( 'edit_user_profile_update', 'save_accountability_opt_in' );
function save_accountability_opt_in( $user_id ) {
if ( !current_user_can( 'edit_user', $user_id ) )
return false;
update_usermeta( $user_id, 'checkin', $_POST['checkin'] );
}
So, at the bottom of the user profile, it now shows this:
Checking to see if opted in user has submitted a post in the last 28 hours
Outline:
- I trigger this by hitting the URL once a night. So no one else can trigger it, hit it with a long random key as a query string, which I first get and check.
- Load WordPress so I can use pre-built functions.
- Get list of opted-in users.
- Check to see if they have posts. If so, get the most recent one. If not, send a webhook to Zapier.
- If they have posts, check the date of the most recent one. If it is within the last 28 hours, do nothing. If not, send a webhook to Zapier. 28 hours gives a little wiggle room for early/late posts.
I like to leave the echos on for debugging if something goes wrong. Like, for example, if you use the_date
instead of get_the_date
and occasionally get NULL instead of a date and can’t figure out why. (Hint: the_date
only fires once in a loop. Thanks for the help debugging, Eric Davis!)
Also, I know I’m using two different types for formatting for if statements. It helps me keep them separate.
<?php
$key = $_GET['key'];
if ($key == 'RANDOM_KEY' ) {
require_once($_SERVER['DOCUMENT_ROOT'] . '/wp-load.php');
function get_user_by_meta_data( $meta_key, $meta_value ) {
// Query for users based on the meta data
$user_query = new WP_User_Query(
array(
'meta_key' => $meta_key,
'meta_value' => $meta_value
)
);
// Get the results from the query, returning the users
$users = $user_query->get_results();
return $users;
}
$opted_in_users = get_user_by_meta_data( 'checkin', 'True');
foreach ($opted_in_users as $user) {
echo $user->ID . ' ' . $user->user_email . '<br />';
$post_query = new WP_Query( array(
'post_type' => 'usp_post',
'author' => $user->ID,
'post_status' => 'publish',
'orderby' => 'date',
'order' => 'DESC',
'showposts' => '1',
) );
// The Loop
if ( $post_query->have_posts() ) :
while ( $post_query->have_posts() ) : $post_query->the_post();
$date = get_the_date('U');
echo 'last post date: ' . $date . '<br />';
//echo 'now: ' . time() . '<br />';
if( $date > (time() - 100800)) {
echo 'Within 28 hours <br /><br />';
continue;
} else {
echo 'Not within 28 hours <br /><br />';
// Initialize curl
$curl = curl_init();
$data = array(
'user_id' => $user->ID,
'email' => $user->user_email,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'last_post' => $date,
);
$jsonEncodedData = json_encode($data);
$opts = array(
CURLOPT_URL => 'https://hooks.zapier.com/hooks/catch/1503890/aqztv5/',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POST => 1,
CURLOPT_POSTFIELDS => $jsonEncodedData,
CURLOPT_HTTPHEADER => array('Content-Type: application/json','Content-Length: ' . strlen($jsonEncodedData))
);
// Set curl options
curl_setopt_array($curl, $opts);
// Get the results
$result = curl_exec($curl);
// Close resource
curl_close($curl);
echo $result;
}
endwhile;
else :
echo "No posts <br /><br />";
// Initialize curl
$curl = curl_init();
$data = array(
'user_id' => $user->ID,
'email' => $user->user_email,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
);
$jsonEncodedData = json_encode($data);
$opts = array(
CURLOPT_URL => 'https://hooks.zapier.com/hooks/catch/1503890/aqztv5/',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POST => 1,
CURLOPT_POSTFIELDS => $jsonEncodedData,
CURLOPT_HTTPHEADER => array('Content-Type: application/json','Content-Length: ' . strlen($jsonEncodedData))
);
// Set curl options
curl_setopt_array($curl, $opts);
// Get the results
$result = curl_exec($curl);
// Close resource
curl_close($curl);
echo $result;
endif;
}
} else {
exit("You are not authorized. Go away.");
}
?>
Template for viewing posts by user
Viewing posts in the wp-admin area is less than ideal. They are grouped by date instead of author and the full contents doesn’t show unless you click. No want to view updates at a glance. So I made a template for that.
// First, check if user is an admin.
if ( !is_user_logged_in() || !current_user_can('administrator') ) {
wp_redirect( site_url() ); exit;
} else {
// User IDs are passed via query string. If no string, show all names
$user = $_GET['id'];
if ( $user == null ) {?>
<h2>Participants who have submitted check-ins:</h2>
<?php
$args1 = array(
//'role' => 'author',
'orderby' => 'first_name',
'order' => 'ASC'
);
$authors = get_users($args1);
foreach ($authors as $user) {
$user_post_count = count_user_posts( $user->ID , 'usp_post' );
if ( $user_post_count > 0) {
echo '<a href="/check-ins/?id=' . $user->id . '">' . $user->first_name . ' ' . $user->last_name . '</a><br />';
}
}
} else {
$user = get_user_by('id', $user);
$post_query = new WP_Query( array(
'post_type' => 'usp_post',
'author' => $user->ID,
'orderby' => 'date',
'order' => 'DESC',
) ); ?>
<br />
<p><a href="/check-ins/">← Back to all participants with check-ins</a></p>
<h2><?php echo $user->first_name . ' ' . $user->last_name .'\'s check-in posts';?></h2>
<?php
if ( $post_query->have_posts() ) :
while( $post_query->have_posts() ) : $post_query->the_post(); ?>
<article <?php post_class(); ?>>
<h3><?php the_time('F j, Y \a\t g:i a') ?></h3>
<div><?php the_content(); ?></div>
</article>
<?php
endwhile;
else:
echo "<p>You have no check-in posts. Submit one above!</p>";
endif;
}
}
Here is what the template outputs:
Hiding check-in posts from search
The best way to do this is to hook in to when the post type is initiated and toggle the ‘exclude_from_search option so ‘true’. I added this to functions.php
:
add_action( 'init', 'usp_post_hide_search', 99 );
function usp_post_hide_search() {
global $wp_post_types;
if ( post_type_exists( 'usp_post' ) ) {
// exclude from search results
$wp_post_types['usp_post']->exclude_from_search = true;
}
}
Using Zapier
I opted to use Zapier for two things:
- Triggering the nightly post checking script via GET.
wp_cron
isn’t super reliable if you need something to run at a given time, and since I’m hosting on WPengine, I don’t have access to the server cron. We already use Zapier for a ton of stuff, so I fire off a GET to my script with the query string random key. - Sending the email and logging emails sent. I could have done this all in the script, but I wanted staff to be able to easily change the email and possibly add more post-email items like sending a message to Slack and Salesforce, or sending a text message to the user.
Going Forward
Here are a few ways I want to improve this when I have time:
- Extend the template that staff uses to see check-in posts to include filter criteria.
- Let users opt-in and opt-out of automated accountability instead of admins opting them in or out.
- I might want to expire or hide posts
- USP by default doesn’t preserve line breaks. I’ll need to update that.