Baseline: The current state of the mess

This commit is contained in:
2026-05-05 17:35:02 -05:00
commit 1672f922fd
10 changed files with 2089 additions and 0 deletions
+377
View File
@@ -0,0 +1,377 @@
<?php
if ( ! defined( 'ABSPATH' ) ) exit;
class KTC_Logic {
public static function init() {
// Use template_redirect for front-end and admin_init for back-end
add_action( 'template_redirect', array( __CLASS__, 'handle_actions' ) );
add_action( 'admin_init', array( __CLASS__, 'handle_actions' ), 5 );
// Standard WP-Cron
if ( ! wp_next_scheduled( 'ktc_zombie_shift_check' ) ) { wp_schedule_event( time(), 'hourly', 'ktc_zombie_shift_check' ); }
add_action( 'ktc_zombie_shift_check', array( __CLASS__, 'scan_for_zombie_shifts' ) );
// Manual Trigger for System Cron (e.g., /usr/local/bin/php wp-cli.phar eval "KTC_Logic::scan_for_zombie_shifts();")
// Or via a GET request: yoursite.com/?ktc_manual_cron=[secret_key]
add_action( 'init', function() {
if ( isset($_GET['ktc_manual_cron']) && $_GET['ktc_manual_cron'] === get_option('ktc_cron_secret') ) {
self::scan_for_zombie_shifts();
exit('Cron Executed');
}
});
// END init()
}
public static function install() {
global $wpdb;
$table_name = $wpdb->prefix . 'ktc_logs';
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE $table_name (
id bigint(20) NOT NULL AUTO_INCREMENT,
user_id bigint(20) NOT NULL,
timestamp datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
proposed_timestamp datetime DEFAULT NULL,
action_type varchar(20) NOT NULL,
notes text,
status varchar(20) DEFAULT 'approved' NOT NULL,
PRIMARY KEY (id)
) $charset_collate;";
require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
dbDelta( $sql );
if ( ! get_role( 'ktc_user' ) ) {
add_role( 'ktc_user', 'KTC Timeclock User', array( 'read' => true ) );
}
$page_slug = 'clock';
$page_check = get_page_by_path($page_slug);
if ( ! $page_check ) {
$page_id = wp_insert_post( array(
'post_title' => 'Clock',
'post_content' => '[ktc_portal_welcome] [krista_timeclock]',
'post_status' => 'publish',
'post_type' => 'page',
'post_name' => $page_slug
));
} else {
$page_id = $page_check->ID;
}
if ( $page_id ) {
update_option( 'show_on_front', 'page' );
update_option( 'page_on_front', $page_id );
}
}
// --- Time Core Methods ---
public static function get_ktc_timezone() {
return new DateTimeZone( get_option( 'ktc_timezone', 'America/Chicago' ) );
}
public static function get_now() {
return new DateTime( 'now', self::get_ktc_timezone() );
}
public static function get_local_timestamp( $date_string ) {
try {
$dt = new DateTime( $date_string, self::get_ktc_timezone() );
return $dt->getTimestamp();
} catch ( Exception $e ) {
return current_time( 'timestamp', 1 );
}
}
// --- Action Handlers ---
public static function handle_actions() {
if ( ! is_user_logged_in() ) return;
// Admin Approval Actions
if ( isset( $_GET['ktc_admin_action'] ) && current_user_can( 'manage_options' ) ) {
self::handle_admin_actions();
return;
}
// POST Route Guard
if ( ! isset( $_POST['ktc_action'] ) ) return;
if ( ! isset( $_POST['ktc_nonce'] ) || ! wp_verify_nonce( $_POST['ktc_nonce'], 'ktc_punch_action' ) ) {
wp_die( 'Security check failed. Please refresh the page.' );
}
$action = $_POST['ktc_action'];
switch ( $action ) {
case 'punch':
self::execute_punch_request();
break;
case 'edit_request':
self::execute_edit_request();
break;
}
wp_safe_redirect( home_url('/') );
exit;
}
/**
* THE DOUBLE-IN GUARD (Missing in previous version)
*/
private static function execute_punch_request() {
global $wpdb;
$user_id = get_current_user_id();
$table_name = $wpdb->prefix . 'ktc_logs';
// 1. Force a clean check of the LAST recorded action
$last_action = $wpdb->get_var( $wpdb->prepare(
"SELECT action_type FROM $table_name WHERE user_id = %d ORDER BY id DESC LIMIT 1",
$user_id
));
// 2. The Toggle Logic: If last was 'clock_in', next is 'clock_out'.
// Anything else (including NULL) results in 'clock_in'.
$next_action = ( $last_action === 'clock_in' ) ? 'clock_out' : 'clock_in';
// 3. Debug/Audit Trail: Ensure we are sending what we think we are
return $wpdb->insert(
$table_name,
array(
'user_id' => $user_id,
'timestamp' => self::get_now()->format('Y-m-d H:i:s'),
'action_type' => $next_action,
'status' => 'approved'
),
array( '%d', '%s', '%s', '%s' )
);
}
private static function execute_edit_request() {
global $wpdb;
$table_name = $wpdb->prefix . 'ktc_logs';
$user_id = get_current_user_id();
$log_id = intval( $_POST['ktc_edit_id'] );
$orig = $wpdb->get_var( $wpdb->prepare(
"SELECT timestamp FROM $table_name WHERE id = %d AND user_id = %d",
$log_id, $user_id
) );
if ( $orig ) {
$tz = self::get_ktc_timezone();
$date_part = (new DateTime($orig, $tz))->format('Y-m-d');
$proposed = $date_part . ' ' . sanitize_text_field( $_POST['ktc_proposed_time'] ) . ':00';
$wpdb->update( $table_name, [
'proposed_timestamp' => $proposed,
'status' => 'pending_correction',
'notes' => sanitize_textarea_field( $_POST['ktc_edit_notes'] )
], [ 'id' => $log_id ]);
}
}
private static function handle_admin_actions() {
global $wpdb;
$table_name = $wpdb->prefix . 'ktc_logs';
$log_id = intval( $_GET['log_id'] );
if ( $_GET['ktc_admin_action'] === 'approve' ) {
$wpdb->update( $table_name, [ 'status' => 'approved' ], [ 'id' => $log_id ] );
} else {
// Reject: Keep approved status but clear the proposed edit
$wpdb->update( $table_name, [ 'status' => 'approved', 'proposed_timestamp' => null ], [ 'id' => $log_id ] );
}
wp_safe_redirect( home_url('/') );
exit;
}
// --- Statistics & Calculations ---
public static function get_elapsed_time( $user_id ) {
global $wpdb;
$table_name = $wpdb->prefix . 'ktc_logs';
$last = $wpdb->get_row( $wpdb->prepare( "SELECT timestamp, action_type FROM $table_name WHERE user_id = %d ORDER BY id DESC LIMIT 1", $user_id ));
if ( ! $last || $last->action_type !== 'clock_in' ) return 0;
$tz = self::get_ktc_timezone();
$now = self::get_now();
$start = new DateTime($last->timestamp, $tz);
return $now->getTimestamp() - $start->getTimestamp();
}
public static function get_weekly_total( $user_id ) {
global $wpdb;
$table_name = $wpdb->prefix . 'ktc_logs';
$now = self::get_now();
$week_start_day = get_option( 'ktc_week_start', 'Monday' );
$start_of_week_ts = ( $now->format('l') === $week_start_day )
? strtotime( "today midnight", $now->getTimestamp() )
: strtotime( "last $week_start_day midnight", $now->getTimestamp() );
$logs = $wpdb->get_results( $wpdb->prepare(
"SELECT timestamp, proposed_timestamp, action_type FROM $table_name
WHERE user_id = %d AND timestamp >= %s AND status = 'approved' ORDER BY timestamp ASC",
$user_id, wp_date( 'Y-m-d H:i:s', $start_of_week_ts )
));
$total_seconds = 0; $temp_in = null;
foreach ( $logs as $log ) {
$ts = !empty($log->proposed_timestamp) ? self::get_local_timestamp($log->proposed_timestamp) : self::get_local_timestamp($log->timestamp);
if ( $log->action_type === 'clock_in' ) { $temp_in = $ts; }
elseif ( $log->action_type === 'clock_out' && $temp_in ) {
$total_seconds += ( $ts - $temp_in );
$temp_in = null;
}
}
return round( $total_seconds / 3600, 2 );
}
public static function get_user_stats( $user_id ) {
global $wpdb;
$table_logs = $wpdb->prefix . 'ktc_logs';
$table_off = $wpdb->prefix . 'ktc_timeoff';
$tz = self::get_ktc_timezone();
$month_start = (new DateTime('first day of this month midnight', $tz))->format('Y-m-d H:i:s');
// Month Total Query
$logs = $wpdb->get_results( $wpdb->prepare(
"SELECT timestamp, proposed_timestamp, action_type FROM $table_logs
WHERE user_id = %d AND timestamp >= %s AND status = 'approved' ORDER BY timestamp ASC",
$user_id, $month_start
));
$total_seconds = 0; $temp_in = null;
foreach ( $logs as $log ) {
$ts = !empty($log->proposed_timestamp) ? self::get_local_timestamp($log->proposed_timestamp) : self::get_local_timestamp($log->timestamp);
if ( $log->action_type === 'clock_in' ) { $temp_in = $ts; }
elseif ( $log->action_type === 'clock_out' && $temp_in ) {
$total_seconds += ( $ts - $temp_in );
$temp_in = null;
}
}
return [
'week' => self::get_weekly_total( $user_id ),
'month' => round( $total_seconds / 3600, 2 ),
'pending_tor' => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $table_off WHERE user_id = %d AND status = 'pending'", $user_id ) ) ?: 0
];
}
/**
* Provides the dashboard stats for the Admin view.
* Restored to fix the Fatal Error in class-admin.php
*/
public static function get_admin_pulse() {
global $wpdb;
$table_logs = $wpdb->prefix . 'ktc_logs';
$table_off = $wpdb->prefix . 'ktc_timeoff';
return [
'active_now' => $wpdb->get_var("SELECT COUNT(*) FROM $table_logs WHERE action_type = 'clock_in' AND status = 'approved'"),
'pending_edits' => $wpdb->get_var("SELECT COUNT(*) FROM $table_logs WHERE status = 'pending_correction'"),
'total_pending_tor' => $wpdb->get_var("SELECT COUNT(*) FROM $table_off WHERE status = 'pending'") ?: 0
];
}
/**
* Formats raw seconds into a human-readable Hh Mm Ss format.
* Restored to fix the Fatal Error on line 157 of class-admin.php
*/
public static function format_seconds( $seconds ) {
$hours = floor( $seconds / 3600 );
$minutes = floor( ( $seconds % 3600 ) / 60 );
$seconds = $seconds % 60;
$output = '';
if ( $hours > 0 ) $output .= "{$hours}h ";
if ( $minutes > 0 ) $output .= "{$minutes}m ";
$output .= "{$seconds}s";
return trim( $output );
}
/**
* Scans for active shifts exceeding the safety threshold.
* Triggered hourly via wp_cron.
*/
public static function scan_for_zombie_shifts() {
global $wpdb;
$threshold = get_option( 'ktc_alert_threshold', 12 );
$table = $wpdb->prefix . 'ktc_logs';
$active_shifts = $wpdb->get_results("
SELECT t1.user_id, t1.timestamp
FROM $table t1
INNER JOIN (
SELECT user_id, MAX(id) as max_id FROM $table GROUP BY user_id
) t2 ON t1.id = t2.max_id
WHERE t1.action_type = 'clock_in'
");
foreach ( $active_shifts as $shift ) {
$start_time = new DateTime( $shift->timestamp, self::get_ktc_timezone() );
$diff_hours = (self::get_now()->getTimestamp() - $start_time->getTimestamp()) / 3600;
if ( $diff_hours > $threshold ) {
self::dispatch_alerts( $shift->user_id, round($diff_hours, 1) );
}
}
}
private static function dispatch_alerts( $user_id, $hours ) {
$user = get_userdata( $user_id );
if ( ! $user ) return;
// Cooldown check (once per 12 hours)
$last_sent = get_user_meta( $user_id, '_ktc_last_zombie_alert', true );
if ( $last_sent && ( time() - $last_sent < 43200 ) ) return;
$subject = "⚠️ Long Shift Alert: " . $user->display_name;
$message = "User is still clocked in after $hours hours.";
// 1. Email Alert
wp_mail( $user->user_email, $subject, $message );
// 2. Teams Alert (if enabled)
if ( get_option( 'ktc_enable_teams' ) && get_option( 'ktc_teams_webhook' ) ) {
self::send_to_teams( $user->display_name, $hours );
}
update_user_meta( $user_id, '_ktc_last_zombie_alert', time() );
}
private static function send_to_teams( $name, $hours ) {
$webhook_url = get_option( 'ktc_teams_webhook' );
$payload = [
"@type" => "MessageCard",
"@context" => "http://schema.org/extensions",
"themeColor" => "d32f2f",
"summary" => "Zombie Shift Detected",
"sections" => [[
"activityTitle" => "⚠️ Zombie Shift Alert",
"activitySubtitle" => "Threshold Exceeded",
"facts" => [
["name" => "User:", "value" => $name],
["name" => "Duration:", "value" => $hours . " hours"],
["name" => "Status:", "value" => "Flagged for Correction"]
],
"markdown" => true
]]
];
wp_remote_post( $webhook_url, [
'body' => json_encode($payload),
'headers' => ['Content-Type' => 'application/json'],
]);
}
}