Fresh start: PHP project baseline
This commit is contained in:
Executable
+377
@@ -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'],
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user