diff --git a/services/nginx/app/cron/Cron.php b/services/nginx/app/cron/Cron.php index 43c45134..848b1910 100644 --- a/services/nginx/app/cron/Cron.php +++ b/services/nginx/app/cron/Cron.php @@ -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'];