Files
api/services/nginx/app/cron/Cron.php
T
Jeppe Bundgaard 8528dd82f4 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.
2026-01-27 10:15:40 +01:00

392 lines
15 KiB
PHP

<?php
// prevent direct access
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;
if (!defined('WD')) {
exit;
}
// Define the warning function
function warn($message): void
{
echo "\n\033[33m$message\033[0m\n";
}
$response_cron = [];
// Define the cron tasks
$cron_tasks = [
// 'CheckUnfulfilledBookings' => [
// 'interval' => 86400, // 24 hours
// 'last_run' => 0,
// 'next_run' => 0,
// 'time' => '15:00',
// 'function' => 'checkUnfulfilledBookings',
// ],
// 'SyncBookings' => [
// 'interval' => 60, // 1 minute
// 'last_run' => 0,
// 'next_run' => 0,
// 'function' => 'syncBookings',
// ],
'SyncLogs' => [
'interval' => 300, // 5 minutes
'last_run' => 0,
'next_run' => 0,
'function' => 'syncLogsToDatabase',
],
'SyncUserEconomicCustomerDiscounts' => [
'interval' => 180, // 3 minutes
'last_run' => 0,
'next_run' => 0,
'function' => 'SyncUserEconomicCustomerDiscounts',
],
'SyncUserEconomicCustomerDetails' => [
'interval' => 43200, // 12 hours
'last_run' => 0,
'next_run' => 0,
'function' => 'SyncUserEconomicCustomerDetails',
],
'backup' => [
'interval' => 43200, // 12 hours
'last_run' => 0,
'next_run' => 0,
'function' => 'backup',
],
'SyncEconomicInvoiceStatus' => [
'interval' => 120, // 2 minutes
'last_run' => 0,
'next_run' => 0,
'function' => 'SyncEconomicInvoiceStatus',
],
'SyncXLVaskModuleCron' => [
'interval' => 3600, // 1 hour
'last_run' => 0,
'next_run' => 0,
'function' => 'SyncXLVaskModuleCron',
],
'GoalsProgressAlertsCron' => [
'interval' => 60, // check every minute
'last_run' => 0,
'next_run' => 0,
'function' => 'GoalsProgressAlertsCron',
],
];
function checkUnfulfilledBookings(): void
{
// This is deactivated for now, as it is not wanted.
// I'm saving this for later, as it is a good idea to have this in place.
return;
$bookings_o = new bookings_o();
$bookings_o->checkUnfulfilledBookings();
}
function syncBookings(): void
{
$bookings_o = new bookings_o();
$response_cron[] = $bookings_o->syncBookings();
}
function syncLogsToDatabase(): void
{
$logs_o = new logs_o();
$logs_o->syncLogsToDatabase();
}
function backup(): void
{
try {
$backup = new backup_store();
$backup->createBackup();
} catch (Exception $e) {
warn('Backup failed: ' . $e->getMessage());
}
}
function SyncUserEconomicCustomerDiscounts(): void
{
$users_o = new users_o();
$users_o->clearAllUsersEconomicCustomerDiscountsFromCache();
}
function SyncUserEconomicCustomerDetails(): void
{
$users_o = new users_o();
$users_o->clearAllUsersEconomicCustomerDetailsFromCache();
//$users_o->syncAllUsersEconomicCustomerDetails();
}
/**
* @throws Exception
*/
function SyncEconomicInvoiceStatus(): void
{
$economic = new economic();
try {
$economic->getTasks()->runCheckErrors();
} catch (Exception $e) {
// Do nothing, this is automatically running
}
try {
$economic->getTasks()->runCheckDrafts();
} catch (Exception $e) {
// Do nothing, this is automatically running
}
}
function SyncXLVaskModuleCron(): void
{
$xlvask = new xlvask();
try {
if ($xlvask->config->enabled->isTrue()) {
$xlvask->getTasks()->runCronTasks();
}
} catch (Exception $e) {
// Do nothing, this is automatically running
}
}
/**
* 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'];
$cron_tasks[$task]['last_run'] = $lastRun;
$cron_tasks[$task]['next_run'] = $nextRun;
if ($nextRun <= time()) {
$data['function']();
redis->set_last_crond_run($task, time());
} else {
$response_cron[] = $task . ' is not due to run yet, next run is at ' . date('Y-m-d H:i:s', $nextRun) . ' (' . ($nextRun - time()) . ' seconds)';
}
}