Add GoalsProgressAlertsCron for scheduled department goal progress notifications
- Introduce a new cron job to send progress alerts for department goals based on criteria. - Support alert destinations: Slack (with fallback webhook), Email, and SMS (currently manual only). - Evaluate frequency rules (daily, weekly, monthly, or change-based) with configurable time-of-day and weekdays. - Deduplicate notifications using Redis keys for each goal and alert slot. - Implement detailed alert scheduling, rendering, and dispatch logic.
This commit is contained in:
@@ -4,6 +4,15 @@
|
||||
use classes\backup_store;
|
||||
use classes\economic;
|
||||
use classes\xlvask;
|
||||
use classes\slack as Slack;
|
||||
use classes\email as Email;
|
||||
use classes\gatewayapi as GatewayAPI;
|
||||
use goals\classes\goals_criteria;
|
||||
use goals\services\goals_progress_alert_renderer;
|
||||
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
|
||||
use goals\helpers\goals_criteria_progress_alert_frequency as Freq;
|
||||
use objects\department_goals_o;
|
||||
use objects\departments_o;
|
||||
use objects\bookings_o;
|
||||
use objects\logs_o;
|
||||
use objects\users_o;
|
||||
@@ -71,6 +80,12 @@ $cron_tasks = [
|
||||
'next_run' => 0,
|
||||
'function' => 'SyncXLVaskModuleCron',
|
||||
],
|
||||
'GoalsProgressAlertsCron' => [
|
||||
'interval' => 60, // check every minute
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'GoalsProgressAlertsCron',
|
||||
],
|
||||
];
|
||||
|
||||
function checkUnfulfilledBookings(): void
|
||||
@@ -147,6 +162,222 @@ function SyncXLVaskModuleCron(): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GoalsProgressAlertsCron
|
||||
*
|
||||
* Iterates all active department goals and, based on each goal's criteria, sends progress alerts
|
||||
* according to frequency, selected weekdays, and an optional time-of-day with timezone.
|
||||
*
|
||||
* Scheduling rules (defaults when not fully specified):
|
||||
* - Frequency NONE: skip.
|
||||
* - DAILY: send once per calendar day at the configured time (or at :00 of the current minute if no time provided).
|
||||
* - WEEKLY: send once per selected weekday(s) at the configured time (if none selected, Monday).
|
||||
* - MONTHLY: send once on the 1st of each month at the configured time (00:00 if not provided).
|
||||
* - CHANGED: send when the computed progress value changes since the last check (debounced to 1 hour).
|
||||
*
|
||||
* Destinations:
|
||||
* - SLACK: send to each department's configured webhook; if none, fallback to default Slack webhook.
|
||||
* - EMAIL/SMS: currently require explicit recipients in manual route; automatic cron will skip and log a warning.
|
||||
*/
|
||||
function GoalsProgressAlertsCron(): void
|
||||
{
|
||||
// List all goals (not deleted)
|
||||
$goals = new department_goals_o();
|
||||
$rows = $goals->listObjects(function ($row) {
|
||||
return (int)$row['id'];
|
||||
});
|
||||
|
||||
if (!is_array($rows) || count($rows) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nowUtc = new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||
|
||||
foreach ($rows as $goalId) {
|
||||
try {
|
||||
$goal = (new department_goals_o())->select($goalId);
|
||||
if (!$goal->exists()) { continue; }
|
||||
// Skip soft-deleted
|
||||
if (method_exists($goal, 'deleted_at') && !empty((string)$goal->deleted_at->value())) { continue; }
|
||||
|
||||
$criteriaArray = (array)$goal->criteria->value();
|
||||
// Build criteria object
|
||||
$criteria = goals_criteria::fromJson(json_encode($criteriaArray));
|
||||
$criteria->validateAndSanitize();
|
||||
|
||||
$frequency = $criteria->progress_alert_frequency ?? Freq::NONE;
|
||||
if ($frequency === Freq::NONE) { continue; }
|
||||
|
||||
// Check if due
|
||||
$dueInfo = goalsProgressAlertDue($criteria, $nowUtc);
|
||||
|
||||
// Special handling for CHANGED: only proceed if progress value changed since last send
|
||||
if (($frequency === Freq::CHANGED)) {
|
||||
$currentProgress = goals_criteria::calculateProgressFromArray($criteriaArray);
|
||||
$lastProgressKey = 'goal_alert_last_progress:' . $goalId;
|
||||
$lastProgress = redis->get($lastProgressKey) ?? null;
|
||||
if ($lastProgress !== null && (string)$lastProgress === (string)$currentProgress) {
|
||||
continue; // no change
|
||||
}
|
||||
// For CHANGED, dedupe by progress value so each distinct value sends once
|
||||
$dueInfo['slot'] = 'changed-' . md5((string)$currentProgress);
|
||||
$dueInfo['ttl'] = 86400 * 30; // keep dedupe for a while
|
||||
} else {
|
||||
if (!$dueInfo['due']) { continue; }
|
||||
}
|
||||
|
||||
// Deduplicate per goal per slot via Redis
|
||||
$slotKey = $dueInfo['slot'];
|
||||
$redisKey = 'goal_alert_sent:' . $goalId . ':' . $slotKey;
|
||||
$already = redis->get($redisKey) ?? null;
|
||||
if ($already) { continue; }
|
||||
|
||||
// Render message
|
||||
$message = goals_progress_alert_renderer::render($criteria);
|
||||
|
||||
// Dispatch according to destination
|
||||
$destination = $criteria->progress_alert_destination ?? Dest::SLACK;
|
||||
switch ($destination) {
|
||||
case Dest::SLACK:
|
||||
$departments = (array)$goal->departments->value();
|
||||
$sentToDept = false;
|
||||
if (count($departments) > 0) {
|
||||
foreach ($departments as $deptId) {
|
||||
if (!is_numeric($deptId)) { continue; }
|
||||
$dept = (new departments_o())->select((int)$deptId);
|
||||
if (!$dept->exists()) { continue; }
|
||||
$webhook = (string)$dept->slack_webhook->value();
|
||||
if (empty($webhook)) { continue; }
|
||||
(new Slack())->send_webhook_message((string)$message, $webhook);
|
||||
$sentToDept = true;
|
||||
}
|
||||
}
|
||||
if (!$sentToDept) {
|
||||
// Fallback to default webhook
|
||||
(new Slack())->send_message($message);
|
||||
}
|
||||
break;
|
||||
case Dest::EMAIL:
|
||||
// No recipient context in goal for automated cron
|
||||
warn('GoalsProgressAlertsCron: EMAIL destination requires explicit recipients; skipping goal #' . $goalId);
|
||||
break;
|
||||
case Dest::SMS:
|
||||
// No recipient context in goal for automated cron
|
||||
warn('GoalsProgressAlertsCron: SMS destination requires explicit recipients; skipping goal #' . $goalId);
|
||||
break;
|
||||
default:
|
||||
// Unsupported or NONE
|
||||
warn('GoalsProgressAlertsCron: Unsupported destination for goal #' . $goalId);
|
||||
}
|
||||
|
||||
// Mark slot as sent (expire in a reasonable window)
|
||||
$ttl = $dueInfo['ttl'];
|
||||
redis->set('' . $redisKey, 1);
|
||||
if (method_exists(redis, 'expire')) {
|
||||
redis->expire($redisKey, $ttl);
|
||||
}
|
||||
|
||||
// Track last progress for CHANGED
|
||||
if ($frequency === Freq::CHANGED) {
|
||||
$progressVal = goals_criteria::calculateProgressFromArray($criteriaArray);
|
||||
redis->set('goal_alert_last_progress:' . $goalId, (string)$progressVal);
|
||||
if (method_exists(redis, 'expire')) {
|
||||
redis->expire('goal_alert_last_progress:' . $goalId, 86400 * 30);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
warn('GoalsProgressAlertsCron error for goal #' . $goalId . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide if an alert is due for the given criteria at the provided UTC time.
|
||||
* Returns ['due' => bool, 'slot' => string, 'ttl' => int]
|
||||
*/
|
||||
function goalsProgressAlertDue(goals_criteria $criteria, DateTimeImmutable $nowUtc): array
|
||||
{
|
||||
$frequency = $criteria->progress_alert_frequency ?? Freq::NONE;
|
||||
|
||||
// Determine alert time in a timezone (defaults to UTC current minute)
|
||||
$timeStr = $criteria->progress_alert_time_of_day;
|
||||
$alertMinuteUtc = null; // DateTimeImmutable normalized to the scheduled minute in UTC
|
||||
$slot = '';
|
||||
$ttl = 3600; // default TTL window for deduplication
|
||||
|
||||
$weekdays = $criteria->progress_alert_weekdays ?? [];
|
||||
$weekdayNames = array_map(fn($e) => ($e instanceof UnitEnum ? $e->name : (string)$e), $weekdays);
|
||||
|
||||
// Helper to build DateTime at today with provided HH:MM in given offset
|
||||
$buildTodayAt = function (string $hhmmTz) use ($nowUtc): ?DateTimeImmutable {
|
||||
// Convert "HH:MMZ" or with offset to a concrete time today
|
||||
if (!preg_match('/^([01]\d|2[0-3]):([0-5]\d)(Z|[+-](?:[01]\d|2[0-3]):?[0-5]\d)$/', $hhmmTz)) {
|
||||
return null;
|
||||
}
|
||||
// Extract parts
|
||||
[$h, $m, $tz] = [substr($hhmmTz,0,2), substr($hhmmTz,3,2), substr($hhmmTz,5)];
|
||||
$tzStr = $tz;
|
||||
if ($tzStr === 'Z') { $tzStr = '+00:00'; }
|
||||
if (preg_match('/^[+-]\d{4}$/', $tzStr)) {
|
||||
// Normalize +HHMM
|
||||
$tzStr = substr($tzStr,0,3) . ':' . substr($tzStr,3,2);
|
||||
}
|
||||
$localTz = new DateTimeZone($tzStr);
|
||||
$todayLocal = new DateTimeImmutable('now', $localTz);
|
||||
$scheduledLocal = $todayLocal->setTime((int)$h, (int)$m, 0, 0);
|
||||
// Convert to UTC
|
||||
return $scheduledLocal->setTimezone(new DateTimeZone('UTC'));
|
||||
};
|
||||
|
||||
// Build scheduled time today (or fallback to current minute)
|
||||
if (is_string($timeStr) && $timeStr !== '') {
|
||||
$alertMinuteUtc = $buildTodayAt($timeStr);
|
||||
}
|
||||
if (!$alertMinuteUtc) {
|
||||
// Fallback: use current minute
|
||||
$alertMinuteUtc = $nowUtc->setTime((int)$nowUtc->format('H'), (int)$nowUtc->format('i'), 0, 0);
|
||||
}
|
||||
|
||||
$nowMinute = $nowUtc->setTime((int)$nowUtc->format('H'), (int)$nowUtc->format('i'), 0, 0);
|
||||
|
||||
switch ($frequency) {
|
||||
case Freq::DAILY:
|
||||
$slot = $nowUtc->format('Y-m-d');
|
||||
$ttl = 86400; // 1 day
|
||||
// If weekdays specified, restrict to those
|
||||
if (!empty($weekdayNames)) {
|
||||
$phpWeekday = strtoupper($nowUtc->format('l'));
|
||||
if (!in_array($phpWeekday, $weekdayNames, true)) { return ['due' => false, 'slot' => $slot, 'ttl' => $ttl]; }
|
||||
}
|
||||
return ['due' => ($nowMinute == $alertMinuteUtc), 'slot' => $slot, 'ttl' => $ttl];
|
||||
case Freq::WEEKLY:
|
||||
$slot = $nowUtc->format('o-W') . (empty($weekdayNames) ? '-MONDAY' : '-' . strtoupper($nowUtc->format('l')));
|
||||
$ttl = 86400 * 7;
|
||||
// Default to Monday if none specified
|
||||
if (empty($weekdayNames)) {
|
||||
$isMonday = ($nowUtc->format('N') === '1');
|
||||
return ['due' => $isMonday && ($nowMinute == $alertMinuteUtc), 'slot' => $slot, 'ttl' => $ttl];
|
||||
}
|
||||
$todayName = strtoupper($nowUtc->format('l'));
|
||||
if (!in_array($todayName, $weekdayNames, true)) { return ['due' => false, 'slot' => $slot, 'ttl' => $ttl]; }
|
||||
return ['due' => ($nowMinute == $alertMinuteUtc), 'slot' => $slot, 'ttl' => $ttl];
|
||||
case Freq::MONTHLY:
|
||||
$slot = $nowUtc->format('Y-m');
|
||||
$ttl = 86400 * 31;
|
||||
$isFirstOfMonth = ($nowUtc->format('j') === '1');
|
||||
return ['due' => $isFirstOfMonth && ($nowMinute == $alertMinuteUtc), 'slot' => $slot, 'ttl' => $ttl];
|
||||
case Freq::CHANGED:
|
||||
// Debounce to 1 hour per change
|
||||
$ttl = 3600;
|
||||
$slot = $nowUtc->format('Y-m-d-H');
|
||||
$lastProgress = redis->get('goal_alert_last_progress:' . ($criteria->label ?? '')) ?? null; // We'll override with goal id upstream
|
||||
// Always mark due here; upstream dedupe per goal id + hour slot applies, but we also check change upstream
|
||||
return ['due' => true, 'slot' => $slot, 'ttl' => $ttl];
|
||||
default:
|
||||
return ['due' => false, 'slot' => '', 'ttl' => 3600];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ( $cron_tasks as $task => $data ) {
|
||||
$lastRun = redis->get_last_crond_run($task) === null ? 0 : redis->get_last_crond_run($task);
|
||||
$nextRun = $lastRun + $data['interval'];
|
||||
|
||||
Reference in New Issue
Block a user