Automated Accountability Check-ins

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(); ?> class="usp-pro-form"> id="usp-pro" class="usp-pro usp-form-4102"> id="usp-form-4102" class="usp-form" method="post" enctype="multipart/form-data" action="" data-parsley-validate="" data-persist="garlic"> class="usp-fieldset usp-fieldset-default"> for="usp-content" class="usp-label usp-label-content">
What did you do to make today a non-zero day?
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"> type="hidden" name="usp-content-required" value="1">
class="usp-hidden"> // Get first and last name, today's date, set that as the title. echo '. $current_user->user_firstname . ' ' . $current_user->user_lastname . ' ' . $today . '" type="hidden" />'; // Get user ID for author echo '. $current_user->ID . '">'; // Get PHP session ID. Plugin thing. echo '. $ses_id . '">'; ?> type="text" name="usp-verify" id="verify" value="" style="display:none;" class="exclude"> type="hidden" name="usp-form-id" value="4102">
echo do_shortcode('[usp_form id="submit"]'); ?>

class="submitted-checkins">
Your submitted check-ins:
// 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(); ?> post_class(); ?>>
the_time('F j, Y \a\t g:i a') ?>

the_content(); ?> href=" echo get_delete_post_link(); ?>" style="font-size: .8em;">Delete this check-in

endwhile; else: echo "
You have no check-in posts. Submit one above!
"; endif; ?>
} else { ?> style="padding:50px;"> echo do_shortcode('[login_form]'); ?>
} ?>

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 ) { ?>
Daily Accountability
class="form-table">
for="checkin">Opt in to daily check-in accountability? | type="checkbox" name="checkin" id="checkin" if (get_the_author_meta( 'checkin', $user->ID) == 'True' ) { ?>checked="checked" }?> value="True" /> /> class="description">Yes, opt me in.

} 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.

$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 . '
'; $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 . '
'; //echo 'now: ' . time() . '
'; if( $date > (time() - 100800)) { echo 'Within 28 hours

'; continue; } else { echo 'Not within 28 hours

'; // 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

"; // 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 ) {?>
Participants who have submitted check-ins:
$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 '. $user->id . '">' . $user->first_name . ' ' . $user->last_name . '
'; } } } else { $user = get_user_by('id', $user); $post_query = new WP_Query( array( 'post_type' => 'usp_post', 'author' => $user->ID, 'orderby' => 'date', 'order' => 'DESC', ) ); ?> />
href="/check-ins/">← Back to all participants with check-ins

echo $user->first_name . ' ' . $user->last_name .'\'s check-in posts';?>
if ( $post_query->have_posts() ) : while( $post_query->have_posts() ) : $post_query->the_post(); ?> post_class(); ?>>
the_time('F j, Y \a\t g:i a') ?>

the_content(); ?>

endwhile; else: echo "
You have no check-in posts. Submit one above!
"; 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.