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:
Jeppe Bundgaard
2026-01-27 10:15:40 +01:00
parent 5a8bcfad90
commit 8528dd82f4
+231
View File
@@ -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'];